whassan commited on
Commit
0abfc43
·
verified ·
1 Parent(s): 689a216

Deploy Sensorflow Studio

Browse files
app_backend.py CHANGED
@@ -2447,10 +2447,16 @@ def pipeline_file(path: str):
2447
 
2448
 
2449
  @app.get("/api/dataset/browse")
2450
- def dataset_browse(source_path: str = "data", limit: int = 48):
2451
- """Validate Images Path by listing browsable local frames."""
 
 
 
 
2452
  from sensorflow import pipeline_artifacts as artifacts
2453
- return artifacts.scan_source_images(source_path, limit=limit)
 
 
2454
 
2455
 
2456
  @app.get("/api/mitl/queue")
 
2447
 
2448
 
2449
  @app.get("/api/dataset/browse")
2450
+ def dataset_browse(
2451
+ source_path: str = "data",
2452
+ limit: int = 48,
2453
+ dataset_type: Optional[str] = None,
2454
+ ):
2455
+ """Validate Images Path by listing browsable local driving-relevant frames."""
2456
  from sensorflow import pipeline_artifacts as artifacts
2457
+
2458
+ dtype = dataset_type or load_config().dataset_type
2459
+ return artifacts.scan_source_images(source_path, limit=limit, dataset_type=dtype)
2460
 
2461
 
2462
  @app.get("/api/mitl/queue")
data/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Local data folder
2
+
3
+ This folder may contain ad-hoc test assets. **Dataset browse and load filters** exclude
4
+ non-driving media (README assets, logos, generic `test.jpg` files, docs paths, and files
5
+ that do not match AV camera-frame layout when an official dataset type is selected).
6
+
7
+ Place real camera frames under a nested path such as `data/images/train/` or
8
+ `data/camera/front/` for Validate & Browse and Local ingest to pick them up.
sensorflow/adapters/local_adapter.py CHANGED
@@ -23,29 +23,49 @@ VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v"}
23
  DEFAULT_MAX_FRAMES = 10_000
24
 
25
 
26
- def discover_media(path: Path) -> Tuple[List[Path], List[Path]]:
 
 
 
 
27
  """Return (image_files, video_files) under path (file or directory)."""
 
 
 
 
 
 
28
  if path.is_file():
29
  ext = path.suffix.lower()
30
- if ext in IMAGE_EXTS:
31
- return [path], []
32
- if ext in VIDEO_EXTS:
33
- return [], [path]
 
 
34
  return [], []
35
 
36
  if not path.is_dir():
37
  return [], []
38
 
39
- images: List[Path] = []
40
- videos: List[Path] = []
41
  for p in sorted(path.rglob("*")):
42
  if not p.is_file():
43
  continue
44
  ext = p.suffix.lower()
45
- if ext in IMAGE_EXTS:
46
- images.append(p)
47
- elif ext in VIDEO_EXTS:
48
- videos.append(p)
 
 
 
 
 
 
 
 
49
  return images, videos
50
 
51
 
@@ -111,7 +131,7 @@ class LocalSequenceAdapter(VendorAdapter):
111
  "or a video file, then re-run ingest with Local enabled."
112
  )
113
 
114
- images, videos = discover_media(path)
115
  frame_images = list(images)
116
 
117
  decode_dir = Path("runs/pipeline") / sequence_id / "decoded_frames"
 
23
  DEFAULT_MAX_FRAMES = 10_000
24
 
25
 
26
+ def discover_media(
27
+ path: Path,
28
+ *,
29
+ dataset_type: Optional[str] = None,
30
+ ) -> Tuple[List[Path], List[Path]]:
31
  """Return (image_files, video_files) under path (file or directory)."""
32
+ from sensorflow.av_media_filter import (
33
+ IMAGE_EXTS as AV_IMAGE_EXTS,
34
+ VIDEO_EXTS as AV_VIDEO_EXTS,
35
+ partition_media_files,
36
+ )
37
+
38
  if path.is_file():
39
  ext = path.suffix.lower()
40
+ if ext in AV_IMAGE_EXTS:
41
+ images, _ = partition_media_files([path], root=path.parent, dataset_type=dataset_type)
42
+ return images, []
43
+ if ext in AV_VIDEO_EXTS:
44
+ _, excluded = partition_media_files([path], root=path.parent, dataset_type=dataset_type, allow_videos=True)
45
+ return [], [] if excluded else [path]
46
  return [], []
47
 
48
  if not path.is_dir():
49
  return [], []
50
 
51
+ raw_images: List[Path] = []
52
+ raw_videos: List[Path] = []
53
  for p in sorted(path.rglob("*")):
54
  if not p.is_file():
55
  continue
56
  ext = p.suffix.lower()
57
+ if ext in AV_IMAGE_EXTS:
58
+ raw_images.append(p)
59
+ elif ext in AV_VIDEO_EXTS:
60
+ raw_videos.append(p)
61
+
62
+ images, _ = partition_media_files(raw_images, root=path, dataset_type=dataset_type)
63
+ videos, _ = partition_media_files(
64
+ raw_videos,
65
+ root=path,
66
+ dataset_type=dataset_type,
67
+ allow_videos=True,
68
+ )
69
  return images, videos
70
 
71
 
 
131
  "or a video file, then re-run ingest with Local enabled."
132
  )
133
 
134
+ images, videos = discover_media(path, dataset_type=source.get("dataset_type"))
135
  frame_images = list(images)
136
 
137
  decode_dir = Path("runs/pipeline") / sequence_id / "decoded_frames"
sensorflow/av_media_filter.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Heuristic filters for driving / AV pipeline image and video discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
8
+
9
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
10
+ VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v"}
11
+
12
+ # Path segments that almost never contain drivable-scene media.
13
+ EXCLUDED_DIR_SEGMENTS = {
14
+ ".git",
15
+ ".github",
16
+ ".venv",
17
+ "venv",
18
+ "__pycache__",
19
+ "node_modules",
20
+ "dist",
21
+ "build",
22
+ "docs",
23
+ "doc",
24
+ "documentation",
25
+ "marketing",
26
+ "assets",
27
+ "static/icons",
28
+ "icons",
29
+ "badges",
30
+ "screenshots",
31
+ }
32
+
33
+ # Filename stems that are commonly README / branding / test junk.
34
+ JUNK_NAME_STEMS = {
35
+ "logo",
36
+ "icon",
37
+ "favicon",
38
+ "avatar",
39
+ "profile",
40
+ "readme",
41
+ "banner",
42
+ "badge",
43
+ "placeholder",
44
+ "sample",
45
+ "example",
46
+ "demo",
47
+ "test",
48
+ "screenshot",
49
+ "thumbnail",
50
+ "thumb",
51
+ }
52
+
53
+ # Substrings in relative paths that suggest AV camera frames.
54
+ AV_PATH_MARKERS = (
55
+ "camera",
56
+ "cameras",
57
+ "images",
58
+ "frames",
59
+ "frame",
60
+ "front",
61
+ "rear",
62
+ "left",
63
+ "right",
64
+ "rgb",
65
+ "video",
66
+ "sequence",
67
+ "segment",
68
+ "train",
69
+ "val",
70
+ "training",
71
+ "validation",
72
+ "lidar",
73
+ "sensor",
74
+ "recording",
75
+ "drive",
76
+ "driving",
77
+ "scene",
78
+ )
79
+
80
+ OFFICIAL_AV_DATASETS = frozenset({"alpamayo", "waymo", "a2d2"})
81
+
82
+ MIN_DRIVING_IMAGE_BYTES = 10 * 1024 # icons / tiny README assets
83
+
84
+ EXCLUSION_CATEGORIES = (
85
+ "wrong_extension",
86
+ "excluded_path",
87
+ "junk_filename",
88
+ "too_small",
89
+ "non_av_layout",
90
+ )
91
+
92
+
93
+ def _normalize_parts(path: Path) -> Tuple[str, ...]:
94
+ return tuple(part.lower() for part in path.parts)
95
+
96
+
97
+ def _relative_to_root(path: Path, root: Optional[Path]) -> Path:
98
+ if root is None:
99
+ return path
100
+ try:
101
+ return path.resolve().relative_to(root.resolve())
102
+ except ValueError:
103
+ return path
104
+
105
+
106
+ def _has_excluded_dir(path: Path) -> bool:
107
+ parts = _normalize_parts(path)
108
+ for i, part in enumerate(parts):
109
+ if part in EXCLUDED_DIR_SEGMENTS:
110
+ return True
111
+ if part == "assets" and i + 1 < len(parts) and parts[i + 1] in {
112
+ "marketing",
113
+ "branding",
114
+ "icons",
115
+ "logos",
116
+ }:
117
+ return True
118
+ return False
119
+
120
+
121
+ def _junk_filename(path: Path) -> bool:
122
+ stem = path.stem.lower()
123
+ if stem in JUNK_NAME_STEMS:
124
+ return True
125
+ if re.match(r"^(logo|icon|favicon|readme|banner|badge)[-_]?\d*$", stem):
126
+ return True
127
+ return False
128
+
129
+
130
+ def _looks_like_av_layout(relative: Path) -> bool:
131
+ rel_str = str(relative).lower().replace("\\", "/")
132
+ if any(marker in rel_str for marker in AV_PATH_MARKERS):
133
+ return True
134
+ # Nested structure (e.g. data/sequences/001/front.jpg) — not a loose root file.
135
+ return len(relative.parts) >= 2
136
+
137
+
138
+ def classify_media_file(
139
+ path: Path,
140
+ *,
141
+ root: Optional[Path] = None,
142
+ dataset_type: Optional[str] = None,
143
+ allow_videos: bool = True,
144
+ ) -> Tuple[bool, Optional[str]]:
145
+ """Return (accepted, exclusion_category)."""
146
+ if not path.is_file():
147
+ return False, "non_media"
148
+
149
+ ext = path.suffix.lower()
150
+ allowed = IMAGE_EXTS | (VIDEO_EXTS if allow_videos else set())
151
+ if ext not in allowed:
152
+ return False, "wrong_extension"
153
+
154
+ if _has_excluded_dir(path):
155
+ return False, "excluded_path"
156
+
157
+ if _junk_filename(path):
158
+ return False, "junk_filename"
159
+
160
+ if ext in IMAGE_EXTS:
161
+ try:
162
+ size = path.stat().st_size
163
+ except OSError:
164
+ return False, "non_media"
165
+ if size < MIN_DRIVING_IMAGE_BYTES:
166
+ return False, "too_small"
167
+
168
+ relative = _relative_to_root(path, root)
169
+ dtype = (dataset_type or "local").lower()
170
+ if dtype in OFFICIAL_AV_DATASETS and not _looks_like_av_layout(relative):
171
+ return False, "non_av_layout"
172
+
173
+ return True, None
174
+
175
+
176
+ def partition_media_files(
177
+ paths: Iterable[Path],
178
+ *,
179
+ root: Optional[Path] = None,
180
+ dataset_type: Optional[str] = None,
181
+ allow_videos: bool = True,
182
+ ) -> Tuple[List[Path], List[Dict[str, Any]]]:
183
+ """Split paths into accepted driving media and excluded entries with reasons."""
184
+ accepted: List[Path] = []
185
+ excluded: List[Dict[str, Any]] = []
186
+ for path in paths:
187
+ ok, reason = classify_media_file(
188
+ path,
189
+ root=root,
190
+ dataset_type=dataset_type,
191
+ allow_videos=allow_videos,
192
+ )
193
+ if ok:
194
+ accepted.append(path)
195
+ else:
196
+ excluded.append(
197
+ {
198
+ "path": str(path),
199
+ "name": path.name,
200
+ "reason": reason or "excluded_path",
201
+ }
202
+ )
203
+ accepted.sort(key=lambda p: str(p).lower())
204
+ return accepted, excluded
205
+
206
+
207
+ def iter_candidate_files(
208
+ root: Path,
209
+ *,
210
+ allow_videos: bool = True,
211
+ ) -> List[Path]:
212
+ """Collect image/video candidates under root before AV heuristics."""
213
+ exts = set(IMAGE_EXTS)
214
+ if allow_videos:
215
+ exts |= VIDEO_EXTS
216
+
217
+ if root.is_file():
218
+ return [root] if root.suffix.lower() in exts else []
219
+
220
+ if not root.is_dir():
221
+ return []
222
+
223
+ out: List[Path] = []
224
+ for p in root.rglob("*"):
225
+ if p.is_file() and p.suffix.lower() in exts:
226
+ out.append(p)
227
+ return out
228
+
229
+
230
+ def scan_driving_media(
231
+ source_path: str | Path,
232
+ *,
233
+ dataset_type: Optional[str] = None,
234
+ allow_videos: bool = True,
235
+ limit: Optional[int] = None,
236
+ ) -> Dict[str, Any]:
237
+ """Scan a path and return accepted media plus exclusion breakdown."""
238
+ root = Path(source_path)
239
+ if not root.is_absolute():
240
+ root = Path.cwd() / root
241
+
242
+ if not root.exists():
243
+ return {
244
+ "source_path": str(source_path),
245
+ "exists": False,
246
+ "accepted": [],
247
+ "excluded": [],
248
+ "count": 0,
249
+ "excluded_count": 0,
250
+ "excluded_by_reason": {},
251
+ }
252
+
253
+ candidates = iter_candidate_files(root, allow_videos=allow_videos)
254
+ scan_root = root if root.is_dir() else root.parent
255
+ accepted, excluded = partition_media_files(
256
+ candidates,
257
+ root=scan_root,
258
+ dataset_type=dataset_type,
259
+ allow_videos=allow_videos,
260
+ )
261
+
262
+ if limit is not None:
263
+ accepted = accepted[:limit]
264
+
265
+ by_reason: Dict[str, int] = {}
266
+ for item in excluded:
267
+ reason = item.get("reason") or "excluded_path"
268
+ by_reason[reason] = by_reason.get(reason, 0) + 1
269
+
270
+ return {
271
+ "source_path": str(source_path),
272
+ "exists": True,
273
+ "accepted": accepted,
274
+ "excluded": excluded,
275
+ "count": len(accepted),
276
+ "excluded_count": len(excluded),
277
+ "excluded_by_reason": by_reason,
278
+ }
279
+
280
+
281
+ def summarize_exclusions(excluded: Sequence[Dict[str, Any]]) -> Dict[str, int]:
282
+ counts: Dict[str, int] = {}
283
+ for item in excluded:
284
+ reason = item.get("reason") or "excluded_path"
285
+ counts[reason] = counts.get(reason, 0) + 1
286
+ return counts
sensorflow/execution_ops.py CHANGED
@@ -22,8 +22,15 @@ def _resolve(path: str | Path) -> Path:
22
  return p
23
 
24
 
25
- def discover_images(source_path: str, *, max_scan: int = 100_000) -> Dict[str, Any]:
26
- """Discover images under a path; report corrupt/unreadable files."""
 
 
 
 
 
 
 
27
  root = _resolve(source_path)
28
  result: Dict[str, Any] = {
29
  "source_path": str(root),
@@ -32,6 +39,9 @@ def discover_images(source_path: str, *, max_scan: int = 100_000) -> Dict[str, A
32
  "images_discovered": 0,
33
  "images_readable": 0,
34
  "images_corrupt": 0,
 
 
 
35
  "annotations_discovered": 0,
36
  "annotation_objects": 0,
37
  "class_ids": [],
@@ -45,11 +55,11 @@ def discover_images(source_path: str, *, max_scan: int = 100_000) -> Dict[str, A
45
  result["errors"].append(f"Path does not exist: {source_path}")
46
  return result
47
 
48
- images: List[Path] = []
49
  labels: List[Path] = []
50
  if root.is_file():
51
  if root.suffix.lower() in IMAGE_EXTS:
52
- images = [root]
53
  elif root.suffix.lower() in LABEL_EXTS:
54
  labels = [root]
55
  else:
@@ -61,14 +71,31 @@ def discover_images(source_path: str, *, max_scan: int = 100_000) -> Dict[str, A
61
  continue
62
  suf = p.suffix.lower()
63
  if suf in IMAGE_EXTS:
64
- images.append(p)
65
  elif suf in LABEL_EXTS:
66
  labels.append(p)
67
- if len(images) + len(labels) >= max_scan:
68
  result["warnings"].append(f"Scan capped at {max_scan} files")
69
  break
70
 
71
- images = sorted(images)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  labels = sorted(labels)
73
  result["images_discovered"] = len(images)
74
  result["annotations_discovered"] = len(labels)
@@ -158,6 +185,9 @@ def write_load_manifest(discovery: Dict[str, Any], dest_dir: Path) -> Path:
158
  "images_discovered": discovery.get("images_discovered"),
159
  "images_readable": discovery.get("images_readable"),
160
  "images_corrupt": discovery.get("images_corrupt"),
 
 
 
161
  "annotations_discovered": discovery.get("annotations_discovered"),
162
  "annotation_objects": discovery.get("annotation_objects"),
163
  "class_ids": discovery.get("class_ids"),
@@ -175,7 +205,7 @@ def load_and_preprocess(
175
  dataset_type: str = "local",
176
  ) -> Dict[str, Any]:
177
  """Real Load & Preprocess: discovery + optional YAML semantic checks."""
178
- discovery = discover_images(source_path)
179
  yaml_report = None
180
  if yaml_path:
181
  ypath = Path(yaml_path)
@@ -230,6 +260,8 @@ def load_and_preprocess(
230
  "images_discovered": discovery["images_discovered"],
231
  "images_readable": discovery["images_readable"],
232
  "images_corrupt": discovery["images_corrupt"],
 
 
233
  "annotations_discovered": discovery["annotations_discovered"],
234
  "annotation_objects": discovery["annotation_objects"],
235
  "loaded_pct_of_discovered": loaded_pct,
 
22
  return p
23
 
24
 
25
+ def discover_images(
26
+ source_path: str,
27
+ *,
28
+ max_scan: int = 100_000,
29
+ dataset_type: Optional[str] = None,
30
+ ) -> Dict[str, Any]:
31
+ """Discover driving-relevant images under a path; report corrupt/unreadable files."""
32
+ from sensorflow.av_media_filter import partition_media_files
33
+
34
  root = _resolve(source_path)
35
  result: Dict[str, Any] = {
36
  "source_path": str(root),
 
39
  "images_discovered": 0,
40
  "images_readable": 0,
41
  "images_corrupt": 0,
42
+ "images_excluded": 0,
43
+ "excluded_by_reason": {},
44
+ "excluded_samples": [],
45
  "annotations_discovered": 0,
46
  "annotation_objects": 0,
47
  "class_ids": [],
 
55
  result["errors"].append(f"Path does not exist: {source_path}")
56
  return result
57
 
58
+ raw_images: List[Path] = []
59
  labels: List[Path] = []
60
  if root.is_file():
61
  if root.suffix.lower() in IMAGE_EXTS:
62
+ raw_images = [root]
63
  elif root.suffix.lower() in LABEL_EXTS:
64
  labels = [root]
65
  else:
 
71
  continue
72
  suf = p.suffix.lower()
73
  if suf in IMAGE_EXTS:
74
+ raw_images.append(p)
75
  elif suf in LABEL_EXTS:
76
  labels.append(p)
77
+ if len(raw_images) + len(labels) >= max_scan:
78
  result["warnings"].append(f"Scan capped at {max_scan} files")
79
  break
80
 
81
+ scan_root = root if root.is_dir() else root.parent
82
+ images, excluded = partition_media_files(
83
+ raw_images,
84
+ root=scan_root,
85
+ dataset_type=dataset_type,
86
+ allow_videos=False,
87
+ )
88
+ result["images_excluded"] = len(excluded)
89
+ result["excluded_by_reason"] = {
90
+ reason: sum(1 for e in excluded if e.get("reason") == reason)
91
+ for reason in sorted({e.get("reason", "excluded_path") for e in excluded})
92
+ }
93
+ result["excluded_samples"] = excluded[:50]
94
+ if excluded:
95
+ result["warnings"].append(
96
+ f"{len(excluded)} file(s) excluded as non-driving media "
97
+ f"({', '.join(f'{k}: {v}' for k, v in result['excluded_by_reason'].items())})"
98
+ )
99
  labels = sorted(labels)
100
  result["images_discovered"] = len(images)
101
  result["annotations_discovered"] = len(labels)
 
185
  "images_discovered": discovery.get("images_discovered"),
186
  "images_readable": discovery.get("images_readable"),
187
  "images_corrupt": discovery.get("images_corrupt"),
188
+ "images_excluded": discovery.get("images_excluded"),
189
+ "excluded_by_reason": discovery.get("excluded_by_reason"),
190
+ "excluded_samples": discovery.get("excluded_samples"),
191
  "annotations_discovered": discovery.get("annotations_discovered"),
192
  "annotation_objects": discovery.get("annotation_objects"),
193
  "class_ids": discovery.get("class_ids"),
 
205
  dataset_type: str = "local",
206
  ) -> Dict[str, Any]:
207
  """Real Load & Preprocess: discovery + optional YAML semantic checks."""
208
+ discovery = discover_images(source_path, dataset_type=dataset_type)
209
  yaml_report = None
210
  if yaml_path:
211
  ypath = Path(yaml_path)
 
260
  "images_discovered": discovery["images_discovered"],
261
  "images_readable": discovery["images_readable"],
262
  "images_corrupt": discovery["images_corrupt"],
263
+ "images_excluded": discovery.get("images_excluded", 0),
264
+ "excluded_by_reason": discovery.get("excluded_by_reason", {}),
265
  "annotations_discovered": discovery["annotations_discovered"],
266
  "annotation_objects": discovery["annotation_objects"],
267
  "loaded_pct_of_discovered": loaded_pct,
sensorflow/pipeline_artifacts.py CHANGED
@@ -253,9 +253,19 @@ def resolve_file(path_str: str) -> Tuple[Path, str]:
253
  return resolved, media_type or "application/octet-stream"
254
 
255
 
256
- def scan_source_images(source_path: str, limit: int = 48) -> Dict[str, Any]:
257
- """List local images under Dataset Configuration Images Path for validation."""
 
 
 
 
 
 
 
258
  path = Path(source_path)
 
 
 
259
  if not path.exists():
260
  return {
261
  "status": "ok",
@@ -264,19 +274,13 @@ def scan_source_images(source_path: str, limit: int = 48) -> Dict[str, Any]:
264
  "browsable": False,
265
  "count": 0,
266
  "images": [],
 
 
 
267
  "empty_reason": f"Path does not exist: {source_path}",
268
  }
269
 
270
- images: List[Path] = []
271
- if path.is_file() and path.suffix.lower() in IMAGE_EXTS:
272
- images = [path]
273
- elif path.is_dir():
274
- for p in sorted(path.rglob("*")):
275
- if p.is_file() and p.suffix.lower() in IMAGE_EXTS:
276
- images.append(p)
277
- if len(images) >= max(limit, 1):
278
- break
279
- else:
280
  return {
281
  "status": "ok",
282
  "source_path": source_path,
@@ -284,16 +288,24 @@ def scan_source_images(source_path: str, limit: int = 48) -> Dict[str, Any]:
284
  "browsable": False,
285
  "count": 0,
286
  "images": [],
287
- "empty_reason": "Not an image file or directory of images.",
 
 
 
288
  }
289
 
290
- # Full count for directories (cap walk cost for huge trees by reusing discovered list when small)
291
- total = len(images)
292
- if path.is_dir() and total >= limit:
293
- total = sum(1 for p in path.rglob("*") if p.is_file() and p.suffix.lower() in IMAGE_EXTS)
 
 
 
 
 
294
 
295
  previews = []
296
- for img in images[:limit]:
297
  previews.append(
298
  {
299
  "name": img.name,
@@ -302,6 +314,23 @@ def scan_source_images(source_path: str, limit: int = 48) -> Dict[str, Any]:
302
  }
303
  )
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  return {
306
  "status": "ok",
307
  "source_path": source_path,
@@ -309,5 +338,9 @@ def scan_source_images(source_path: str, limit: int = 48) -> Dict[str, Any]:
309
  "browsable": total > 0,
310
  "count": total,
311
  "images": previews,
312
- "empty_reason": None if total else "No images found under this path.",
 
 
 
 
313
  }
 
253
  return resolved, media_type or "application/octet-stream"
254
 
255
 
256
+ def scan_source_images(
257
+ source_path: str,
258
+ limit: int = 48,
259
+ *,
260
+ dataset_type: Optional[str] = None,
261
+ ) -> Dict[str, Any]:
262
+ """List local driving-relevant images under Dataset Configuration Images Path."""
263
+ from sensorflow.av_media_filter import OFFICIAL_AV_DATASETS, scan_driving_media
264
+
265
  path = Path(source_path)
266
+ if not path.is_absolute():
267
+ path = Path.cwd() / path
268
+
269
  if not path.exists():
270
  return {
271
  "status": "ok",
 
274
  "browsable": False,
275
  "count": 0,
276
  "images": [],
277
+ "excluded_count": 0,
278
+ "excluded": [],
279
+ "excluded_by_reason": {},
280
  "empty_reason": f"Path does not exist: {source_path}",
281
  }
282
 
283
+ if path.is_file() and path.suffix.lower() not in IMAGE_EXTS:
 
 
 
 
 
 
 
 
 
284
  return {
285
  "status": "ok",
286
  "source_path": source_path,
 
288
  "browsable": False,
289
  "count": 0,
290
  "images": [],
291
+ "excluded_count": 0,
292
+ "excluded": [],
293
+ "excluded_by_reason": {},
294
+ "empty_reason": "Not an image file or directory of driving media.",
295
  }
296
 
297
+ scan = scan_driving_media(
298
+ path,
299
+ dataset_type=dataset_type,
300
+ allow_videos=False,
301
+ )
302
+ accepted = scan["accepted"]
303
+ excluded = scan["excluded"]
304
+ total = scan["count"]
305
+ dtype = (dataset_type or "local").lower()
306
 
307
  previews = []
308
+ for img in accepted[:limit]:
309
  previews.append(
310
  {
311
  "name": img.name,
 
314
  }
315
  )
316
 
317
+ empty_reason = None
318
+ if total == 0:
319
+ if excluded:
320
+ empty_reason = (
321
+ f"No driving-relevant images found under {source_path} "
322
+ f"({len(excluded)} file(s) excluded as non-AV media)."
323
+ )
324
+ else:
325
+ empty_reason = "No driving-relevant images found under this path."
326
+
327
+ warnings: List[str] = []
328
+ if dtype in OFFICIAL_AV_DATASETS and excluded:
329
+ warnings.append(
330
+ f"Browsed path does not match typical {dtype} layout; "
331
+ "point Images Path at downloaded camera frames or enable Local ingest."
332
+ )
333
+
334
  return {
335
  "status": "ok",
336
  "source_path": source_path,
 
338
  "browsable": total > 0,
339
  "count": total,
340
  "images": previews,
341
+ "excluded_count": scan["excluded_count"],
342
+ "excluded": excluded[:200],
343
+ "excluded_by_reason": scan["excluded_by_reason"],
344
+ "warnings": warnings,
345
+ "empty_reason": empty_reason,
346
  }
static/app.js CHANGED
@@ -922,13 +922,22 @@ document.addEventListener('DOMContentLoaded', () => {
922
  if (btnBrowseSource) {
923
  btnBrowseSource.addEventListener('click', async () => {
924
  const sourcePath = datasetSourcePath?.value || document.getElementById('input-source')?.value || 'data';
 
925
  const statusEl = document.getElementById('source-browse-status');
 
926
  const gallery = document.getElementById('source-browse-gallery');
927
  btnBrowseSource.disabled = true;
928
  btnBrowseSource.textContent = 'Scanning…';
929
  if (statusEl) statusEl.textContent = `Validating ${sourcePath}…`;
 
 
 
 
930
  try {
931
- const res = await fetch(`${API_BASE}/api/dataset/browse?source_path=${encodeURIComponent(sourcePath)}&limit=48`);
 
 
 
932
  const data = await res.json();
933
  if (gallery) {
934
  gallery.innerHTML = '';
@@ -942,20 +951,53 @@ document.addEventListener('DOMContentLoaded', () => {
942
  });
943
  }
944
  if (statusEl) {
945
- statusEl.textContent = data.browsable
946
- ? `Browsable: ${data.count} image(s) under ${data.source_path}`
947
  : (data.empty_reason || 'Nothing browsable at this path.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
948
  }
949
  const badge = document.getElementById('ds-meta-badge');
950
  if (badge) {
951
  if (data.browsable) {
952
- badge.textContent = `${data.count} images browsable on disk`;
953
  badge.style.background = 'rgba(0, 255, 170, 0.15)';
954
  badge.style.color = '#00ffaa';
955
  badge.style.border = '1px solid rgba(0, 255, 170, 0.3)';
956
  document.getElementById('ds-kpi-loaded-rows').textContent = data.count;
957
  } else {
958
- badge.textContent = 'Not browsable — path empty or missing';
959
  badge.style.background = 'rgba(255, 68, 68, 0.12)';
960
  badge.style.color = '#ff8888';
961
  badge.style.border = '1px solid rgba(255, 68, 68, 0.35)';
 
922
  if (btnBrowseSource) {
923
  btnBrowseSource.addEventListener('click', async () => {
924
  const sourcePath = datasetSourcePath?.value || document.getElementById('input-source')?.value || 'data';
925
+ const datasetType = selectDatasetType?.value || 'local';
926
  const statusEl = document.getElementById('source-browse-status');
927
+ const excludedEl = document.getElementById('source-browse-excluded');
928
  const gallery = document.getElementById('source-browse-gallery');
929
  btnBrowseSource.disabled = true;
930
  btnBrowseSource.textContent = 'Scanning…';
931
  if (statusEl) statusEl.textContent = `Validating ${sourcePath}…`;
932
+ if (excludedEl) {
933
+ excludedEl.style.display = 'none';
934
+ excludedEl.innerHTML = '';
935
+ }
936
  try {
937
+ const res = await fetch(
938
+ `${API_BASE}/api/dataset/browse?source_path=${encodeURIComponent(sourcePath)}`
939
+ + `&limit=48&dataset_type=${encodeURIComponent(datasetType)}`
940
+ );
941
  const data = await res.json();
942
  if (gallery) {
943
  gallery.innerHTML = '';
 
951
  });
952
  }
953
  if (statusEl) {
954
+ let msg = data.browsable
955
+ ? `Browsable: ${data.count} driving-relevant image(s) under ${data.source_path}`
956
  : (data.empty_reason || 'Nothing browsable at this path.');
957
+ if (Array.isArray(data.warnings) && data.warnings.length) {
958
+ msg += ` — ${data.warnings[0]}`;
959
+ }
960
+ statusEl.textContent = msg;
961
+ }
962
+ if (excludedEl && data.excluded_count > 0) {
963
+ excludedEl.style.display = 'block';
964
+ const summary = document.createElement('div');
965
+ summary.textContent = `${data.excluded_count} file(s) excluded (not driving media)`;
966
+ const toggle = document.createElement('button');
967
+ toggle.type = 'button';
968
+ toggle.className = 'btn btn-secondary';
969
+ toggle.style.marginTop = '8px';
970
+ toggle.style.fontSize = '12px';
971
+ toggle.textContent = 'Show excluded files';
972
+ const list = document.createElement('ul');
973
+ list.style.display = 'none';
974
+ list.style.marginTop = '8px';
975
+ list.style.fontSize = '12px';
976
+ list.style.color = 'var(--text-muted)';
977
+ (data.excluded || []).forEach((item) => {
978
+ const li = document.createElement('li');
979
+ li.textContent = `${item.name || item.path} (${item.reason || 'excluded'})`;
980
+ list.appendChild(li);
981
+ });
982
+ toggle.addEventListener('click', () => {
983
+ const open = list.style.display === 'none';
984
+ list.style.display = open ? 'block' : 'none';
985
+ toggle.textContent = open ? 'Hide excluded files' : 'Show excluded files';
986
+ });
987
+ excludedEl.appendChild(summary);
988
+ excludedEl.appendChild(toggle);
989
+ excludedEl.appendChild(list);
990
  }
991
  const badge = document.getElementById('ds-meta-badge');
992
  if (badge) {
993
  if (data.browsable) {
994
+ badge.textContent = `${data.count} driving images browsable on disk`;
995
  badge.style.background = 'rgba(0, 255, 170, 0.15)';
996
  badge.style.color = '#00ffaa';
997
  badge.style.border = '1px solid rgba(0, 255, 170, 0.3)';
998
  document.getElementById('ds-kpi-loaded-rows').textContent = data.count;
999
  } else {
1000
+ badge.textContent = 'Not browsable — no driving media at path';
1001
  badge.style.background = 'rgba(255, 68, 68, 0.12)';
1002
  badge.style.color = '#ff8888';
1003
  badge.style.border = '1px solid rgba(255, 68, 68, 0.35)';
static/index.html CHANGED
@@ -153,6 +153,7 @@
153
  </div>
154
  <div id="config-exec-status" class="config-exec-status" style="margin-top:10px;font-size:12px;color:var(--text-muted);"></div>
155
  <div id="source-browse-status" class="status-box" style="margin-top:12px;"></div>
 
156
  <div id="source-browse-gallery" class="output-browse-gallery" style="margin-top:12px;"></div>
157
  <div class="evidence-card" id="evidence-dataset" data-stage="dataset">
158
  <h4>What actually happened?</h4>
 
153
  </div>
154
  <div id="config-exec-status" class="config-exec-status" style="margin-top:10px;font-size:12px;color:var(--text-muted);"></div>
155
  <div id="source-browse-status" class="status-box" style="margin-top:12px;"></div>
156
+ <div id="source-browse-excluded" class="status-box" style="margin-top:8px;display:none;"></div>
157
  <div id="source-browse-gallery" class="output-browse-gallery" style="margin-top:12px;"></div>
158
  <div class="evidence-card" id="evidence-dataset" data-stage="dataset">
159
  <h4>What actually happened?</h4>
tests/test_pipeline/test_av_media_filter.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for AV driving-media browse filtering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from fastapi.testclient import TestClient
11
+
12
+
13
+ def _png_bytes() -> bytes:
14
+ return bytes.fromhex(
15
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
16
+ "0000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082"
17
+ )
18
+
19
+
20
+ def _large_png_bytes(min_bytes: int = 11 * 1024) -> bytes:
21
+ """Valid PNG header with zero padding so size heuristics accept it in tests."""
22
+ base = _png_bytes()
23
+ if len(base) >= min_bytes:
24
+ return base
25
+ return base + b"\x00" * (min_bytes - len(base))
26
+
27
+
28
+ def _write_driving_frame(root: Path, rel: str = "images/train/frame.png") -> Path:
29
+ path = root / rel
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ path.write_bytes(_large_png_bytes())
32
+ return path
33
+
34
+
35
+ @pytest.fixture
36
+ def client(tmp_path, monkeypatch):
37
+ monkeypatch.chdir(tmp_path)
38
+ import app_backend
39
+
40
+ importlib.reload(app_backend)
41
+ return TestClient(app_backend.app)
42
+
43
+
44
+ def test_browse_excludes_junk_test_file_for_alpamayo(client, tmp_path):
45
+ data_dir = tmp_path / "data"
46
+ data_dir.mkdir()
47
+ (data_dir / "test.jpg").write_bytes(_large_png_bytes()) # wrong ext still image-like size
48
+ _write_driving_frame(data_dir, "camera/front/scene_001.jpg").write_bytes(_large_png_bytes())
49
+
50
+ res = client.get(
51
+ "/api/dataset/browse?source_path=data&dataset_type=alpamayo"
52
+ )
53
+ assert res.status_code == 200
54
+ body = res.json()
55
+ assert body["count"] == 1
56
+ assert body["browsable"] is True
57
+ assert body["excluded_count"] >= 1
58
+ names = {img["name"] for img in body["images"]}
59
+ assert "scene_001.jpg" in names
60
+ assert "test.jpg" not in names
61
+ reasons = {e["reason"] for e in body["excluded"]}
62
+ assert "junk_filename" in reasons or "non_av_layout" in reasons
63
+
64
+
65
+ def test_browse_excludes_docs_and_logo_paths(client, tmp_path):
66
+ root = tmp_path / "data"
67
+ _write_driving_frame(root, "frames/road.png")
68
+ logo = root / "docs" / "logo.png"
69
+ logo.parent.mkdir(parents=True)
70
+ logo.write_bytes(_large_png_bytes())
71
+ icon = root / "assets" / "icon.png"
72
+ icon.parent.mkdir(parents=True)
73
+ icon.write_bytes(_large_png_bytes())
74
+
75
+ res = client.get("/api/dataset/browse?source_path=data&dataset_type=local")
76
+ body = res.json()
77
+ assert body["count"] == 1
78
+ assert body["excluded_count"] == 2
79
+ assert all(e["reason"] == "excluded_path" for e in body["excluded"])
80
+
81
+
82
+ def test_browse_excludes_tiny_icons(client, tmp_path):
83
+ root = tmp_path / "data"
84
+ _write_driving_frame(root, "images/train/big.png")
85
+ tiny = root / "images" / "train" / "favicon.png"
86
+ tiny.write_bytes(_png_bytes()) # < 10KB and junk name
87
+
88
+ res = client.get("/api/dataset/browse?source_path=data&dataset_type=local")
89
+ body = res.json()
90
+ assert body["count"] == 1
91
+ assert body["excluded_count"] == 1
92
+ assert body["excluded"][0]["reason"] in {"too_small", "junk_filename"}
93
+
94
+
95
+ def test_browse_lists_valid_driving_media(client, tmp_path):
96
+ root = tmp_path / "data"
97
+ _write_driving_frame(root, "camera/front/a.png")
98
+
99
+ res = client.get("/api/dataset/browse?source_path=data&dataset_type=local")
100
+ assert res.status_code == 200
101
+ body = res.json()
102
+ assert body["browsable"] is True
103
+ assert body["count"] == 1
104
+ assert body["images"][0]["preview_url"].startswith("/api/pipeline/file?")
105
+
106
+
107
+ def test_load_reports_images_excluded(client, tmp_path):
108
+ root = tmp_path / "data"
109
+ _write_driving_frame(root, "images/train/good.png")
110
+ (root / "sample.jpg").write_bytes(_large_png_bytes())
111
+
112
+ res = client.post(
113
+ "/api/dataset/load",
114
+ json={"source_path": str(root), "dataset_type": "local"},
115
+ )
116
+ data = res.json()
117
+ assert data["metrics"]["images_discovered"] == 1
118
+ assert data["metrics"]["images_excluded"] == 1
119
+ assert data["discovery"]["excluded_by_reason"]
120
+
121
+
122
+ def test_repo_data_test_jpg_excluded_when_alpamayo_selected(client, tmp_path, monkeypatch):
123
+ """Regression: loose data/test.jpg must not appear for official AV dataset types."""
124
+ repo_data = Path(__file__).resolve().parents[2] / "data"
125
+ if not (repo_data / "test.jpg").exists():
126
+ pytest.skip("repo data/test.jpg not present")
127
+
128
+ monkeypatch.chdir(tmp_path)
129
+ shutil.copytree(repo_data, tmp_path / "data")
130
+
131
+ import app_backend
132
+
133
+ importlib.reload(app_backend)
134
+ c = TestClient(app_backend.app)
135
+ res = c.get("/api/dataset/browse?source_path=data&dataset_type=alpamayo")
136
+ body = res.json()
137
+ assert body["count"] == 0
138
+ assert body["browsable"] is False
139
+ assert body["excluded_count"] >= 1
140
+ assert not body["images"]
tests/test_pipeline/test_pipeline_browse.py CHANGED
@@ -6,6 +6,14 @@ import pytest
6
  from fastapi.testclient import TestClient
7
 
8
 
 
 
 
 
 
 
 
 
9
  @pytest.fixture
10
  def client(tmp_path, monkeypatch):
11
  monkeypatch.chdir(tmp_path)
@@ -118,14 +126,10 @@ def test_load_all_not_executed_without_stub(client, tmp_path):
118
 
119
 
120
  def test_load_all_local_real_when_path_has_images(client, tmp_path):
121
- data_dir = tmp_path / "data"
122
- data_dir.mkdir()
123
- (data_dir / "a.png").write_bytes(
124
- bytes.fromhex(
125
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
126
- "0000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082"
127
- )
128
- )
129
  res = client.post(
130
  "/api/dataset/load-all",
131
  json={
@@ -174,14 +178,9 @@ def test_pipeline_file_blocks_path_traversal(client, tmp_path):
174
 
175
 
176
  def test_dataset_browse_lists_images(client, tmp_path):
177
- data_dir = tmp_path / "data"
178
- data_dir.mkdir()
179
- (data_dir / "a.png").write_bytes(
180
- bytes.fromhex(
181
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
182
- "0000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082"
183
- )
184
- )
185
  res = client.get("/api/dataset/browse?source_path=data")
186
  assert res.status_code == 200
187
  body = res.json()
 
6
  from fastapi.testclient import TestClient
7
 
8
 
9
+ def _large_png_bytes(min_bytes: int = 11 * 1024) -> bytes:
10
+ base = bytes.fromhex(
11
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
12
+ "0000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082"
13
+ )
14
+ return base if len(base) >= min_bytes else base + b"\x00" * (min_bytes - len(base))
15
+
16
+
17
  @pytest.fixture
18
  def client(tmp_path, monkeypatch):
19
  monkeypatch.chdir(tmp_path)
 
126
 
127
 
128
  def test_load_all_local_real_when_path_has_images(client, tmp_path):
129
+ data_dir = tmp_path / "data" / "images" / "train"
130
+ data_dir.mkdir(parents=True)
131
+ png = _large_png_bytes()
132
+ (data_dir / "a.png").write_bytes(png)
 
 
 
 
133
  res = client.post(
134
  "/api/dataset/load-all",
135
  json={
 
178
 
179
 
180
  def test_dataset_browse_lists_images(client, tmp_path):
181
+ data_dir = tmp_path / "data" / "camera" / "front"
182
+ data_dir.mkdir(parents=True)
183
+ (data_dir / "a.png").write_bytes(_large_png_bytes())
 
 
 
 
 
184
  res = client.get("/api/dataset/browse?source_path=data")
185
  assert res.status_code == 200
186
  body = res.json()