seavision 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.
Files changed (93) hide show
  1. seavision/__init__.py +38 -0
  2. seavision/calibration/README.md +41 -0
  3. seavision/calibration/__init__.py +7 -0
  4. seavision/calibration/__main__.py +45 -0
  5. seavision/calibration/parsers.py +58 -0
  6. seavision/calibration/server.py +265 -0
  7. seavision/calibration/static/assets/SEA.AI_logo_dark_blue_RGB.png +0 -0
  8. seavision/calibration/static/css/base.css +161 -0
  9. seavision/calibration/static/css/components.css +1108 -0
  10. seavision/calibration/static/index.html +224 -0
  11. seavision/calibration/static/js/api.js +54 -0
  12. seavision/calibration/static/js/browser.js +128 -0
  13. seavision/calibration/static/js/controls.js +32 -0
  14. seavision/calibration/static/js/main.js +282 -0
  15. seavision/calibration/static/js/viewer.js +235 -0
  16. seavision/calibration/utils.py +243 -0
  17. seavision/cli/__init__.py +78 -0
  18. seavision/cli/calibration.py +86 -0
  19. seavision/cli/compose_onnx.py +75 -0
  20. seavision/cli/inference.py +351 -0
  21. seavision/cli/models.py +65 -0
  22. seavision/cli/utils.py +96 -0
  23. seavision/deployment/__init__.py +1 -0
  24. seavision/deployment/compose_onnx.py +173 -0
  25. seavision/deployment/onnx2trt.py +141 -0
  26. seavision/deployment/speedtest.py +111 -0
  27. seavision/experimental/README.md +17 -0
  28. seavision/experimental/__init__.py +5 -0
  29. seavision/experimental/false_zoom.py +89 -0
  30. seavision/experimental/video_embeddings.py +159 -0
  31. seavision/fiveone/.gitignore +1 -0
  32. seavision/fiveone/__init__.py +10 -0
  33. seavision/fiveone/composite.py +212 -0
  34. seavision/fiveone/inference.py +393 -0
  35. seavision/fiveone/representativeness.py +86 -0
  36. seavision/geometry/__init__.py +10 -0
  37. seavision/geometry/composite.py +187 -0
  38. seavision/geometry/distance.py +440 -0
  39. seavision/geometry/pinhole.py +274 -0
  40. seavision/geometry/transformations.py +154 -0
  41. seavision/geometry/utils.py +49 -0
  42. seavision/inference/README.md +157 -0
  43. seavision/inference/__init__.py +40 -0
  44. seavision/inference/ahoy.py +154 -0
  45. seavision/inference/dan.py +300 -0
  46. seavision/inference/holy.py +256 -0
  47. seavision/inference/horizon_extraction/__init__.py +11 -0
  48. seavision/inference/horizon_extraction/extract.py +102 -0
  49. seavision/inference/horizon_extraction/fit.py +229 -0
  50. seavision/inference/horizon_extraction/ops.py +146 -0
  51. seavision/inference/infer_abc.py +120 -0
  52. seavision/inference/infer_onnx2torch.py +239 -0
  53. seavision/inference/infer_tf.py +154 -0
  54. seavision/inference/infer_trt.py +315 -0
  55. seavision/inference/louisnet.py +124 -0
  56. seavision/inference/mimo.py +71 -0
  57. seavision/inference/model_abc.py +492 -0
  58. seavision/inference/models.py +192 -0
  59. seavision/inference/postprocessing.py +950 -0
  60. seavision/inference/preprocessing.py +351 -0
  61. seavision/inference/rahi.py +119 -0
  62. seavision/inference/results.py +993 -0
  63. seavision/inference/sahi.py +142 -0
  64. seavision/inference/seayolo.py +101 -0
  65. seavision/inference/slicing.py +186 -0
  66. seavision/inference/utils.py +112 -0
  67. seavision/inference/yolo.py +148 -0
  68. seavision/io/__init__.py +18 -0
  69. seavision/io/image.py +94 -0
  70. seavision/io/utils.py +24 -0
  71. seavision/io/video.py +829 -0
  72. seavision/sensorfusion/__init__.py +17 -0
  73. seavision/sensorfusion/common.py +31 -0
  74. seavision/sensorfusion/fusion_abc.py +179 -0
  75. seavision/sensorfusion/nms.py +62 -0
  76. seavision/sensorfusion/utils.py +17 -0
  77. seavision/sensorfusion/wbf.py +50 -0
  78. seavision/transforms/__init__.py +20 -0
  79. seavision/transforms/base.py +70 -0
  80. seavision/transforms/clip.py +61 -0
  81. seavision/transforms/color.py +17 -0
  82. seavision/transforms/compose.py +104 -0
  83. seavision/transforms/enhance.py +84 -0
  84. seavision/transforms/normalize.py +66 -0
  85. seavision/utils/__init__.py +1 -0
  86. seavision/utils/downloads.py +148 -0
  87. seavision/utils/general.py +138 -0
  88. seavision/utils/jetson.py +202 -0
  89. seavision/utils/onnx_utils.py +140 -0
  90. seavision-1.0.0.dist-info/METADATA +112 -0
  91. seavision-1.0.0.dist-info/RECORD +93 -0
  92. seavision-1.0.0.dist-info/WHEEL +4 -0
  93. seavision-1.0.0.dist-info/entry_points.txt +2 -0
seavision/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """SeaVision.
2
+
3
+ Computer vision tools for R&D at SEA.AI.
4
+ """
5
+
6
+ from . import calibration, geometry, inference, io
7
+ from . import sensorfusion as sf
8
+ from . import transforms as T
9
+ from .calibration import * # noqa: F403
10
+ from .geometry import * # noqa: F403
11
+ from .inference import * # noqa: F403
12
+ from .io import * # noqa: F403
13
+ from .utils.general import pil_side_by_side, pil_up_down
14
+
15
+ try:
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ except ImportError:
18
+ from importlib_metadata import PackageNotFoundError, version # python 3.6 and 3.7
19
+
20
+ try:
21
+ __version__ = version("seavision")
22
+ except PackageNotFoundError:
23
+ __version__ = "0.0.0"
24
+
25
+ __all__ = [
26
+ "geometry",
27
+ "inference",
28
+ "io",
29
+ "calibration",
30
+ "sf",
31
+ "T",
32
+ "pil_side_by_side",
33
+ "pil_up_down",
34
+ ]
35
+ __all__.extend(inference.__all__)
36
+ __all__.extend(io.__all__)
37
+ __all__.extend(geometry.__all__)
38
+ __all__.extend(calibration.__all__)
@@ -0,0 +1,41 @@
1
+ # Online Calibration
2
+
3
+ A local web tool for manually calibrating Sentry camera homography by adjusting pitch, yaw, and roll angles and visually inspecting the overlay against the RGB reference frame.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ uv run -m seavision.calibration
9
+ uv run -m seavision.calibration --recording /path/to/Sentry_recordings_2024_03_12_10_34_06
10
+ uv run -m seavision.calibration --port 8323 --dry-run
11
+ ```
12
+
13
+ Then open http://localhost:8323 in a browser.
14
+
15
+ ## Arguments
16
+
17
+ | Argument | Default | Description |
18
+ |---|---|---|
19
+ | `--recording` | — | Path to a Sentry recording directory. Optional — can also be set via the UI. |
20
+ | `--port` | `8323` | Port to listen on. |
21
+ | `--host` | `0.0.0.0` | Host / IP to bind (`0.0.0.0` = all interfaces). |
22
+ | `--dry-run` | `false` | Print calibration changes to terminal instead of writing to `sentry_sensors.json`. |
23
+
24
+ ## UI
25
+
26
+ - **Recording** — paste a path or use the text input to load a recording directory
27
+ - **Angles** — adjust pitch, yaw, roll for the selected camera; the overlay updates live
28
+ - **Blend** — control the mix weight between the source and reference frame
29
+ - **JSON tab** — toggle between the image overlay and a live view of `sentry_sensors.json`; changed values are highlighted in red
30
+ - **Save** — writes updated values to `sentry_sensors.json`; the original is preserved as `original_sentry_sensors.json`
31
+
32
+ ## Structure
33
+
34
+ ```
35
+ seavision/calibration/
36
+ __main__.py Entry point
37
+ server.py FastAPI server and API endpoints
38
+ utils.py Recording loading, frame extraction, homography processing
39
+ static/
40
+ index.html Single-page UI
41
+ ```
@@ -0,0 +1,7 @@
1
+ """Sentry homography calibration web tool."""
2
+
3
+ from .parsers import CalibrationData
4
+
5
+ __all__ = [
6
+ "CalibrationData",
7
+ ]
@@ -0,0 +1,45 @@
1
+ """Entry point for the Sentry homography calibration web server.
2
+
3
+ Usage:
4
+ uv run -m seavision.calibration
5
+ uv run -m seavision.calibration --recording /path/to/Sentry_2024_03_12_10_34_06
6
+ uv run -m seavision.calibration --port 8323 --host 127.0.0.1 --dry-run
7
+ """
8
+
9
+ import argparse
10
+
11
+ from seavision.calibration.server import run_web_server
12
+
13
+ parser = argparse.ArgumentParser(description="Sentry homography calibration server")
14
+ parser.add_argument(
15
+ "--recording",
16
+ default=None,
17
+ help="Path to a Sentry recording directory (optional — can be set in the UI)",
18
+ )
19
+ parser.add_argument(
20
+ "--port", type=int, default=8323, help="Port to listen on (default: 8323)"
21
+ )
22
+ parser.add_argument(
23
+ "--host",
24
+ default="0.0.0.0",
25
+ help="Host / IP to bind (default: 0.0.0.0 — all interfaces)",
26
+ )
27
+ parser.add_argument(
28
+ "--reference-camera",
29
+ default=None,
30
+ help="Master view to project sources into (default: t_w)",
31
+ )
32
+ parser.add_argument(
33
+ "--dry-run",
34
+ action="store_true",
35
+ help="Print changes instead of writing to sentry_sensors.json",
36
+ )
37
+ args = parser.parse_args()
38
+
39
+ run_web_server(
40
+ args.recording,
41
+ args.port,
42
+ host=args.host,
43
+ dry_run=args.dry_run,
44
+ reference_camera=args.reference_camera,
45
+ )
@@ -0,0 +1,58 @@
1
+ """Parsers for sensor calibration data."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Union
7
+
8
+ from seavision.geometry.pinhole import Camera
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class CalibrationData:
13
+ """Calibration bundle loaded from JSON (Oscar / Sentry or legacy intrinsics)."""
14
+
15
+ cameras: dict[str, Camera]
16
+
17
+ @staticmethod
18
+ def _build_camera(camera_data: Dict[str, Any]) -> Camera:
19
+ """Build a Camera from one JSON camera block."""
20
+ if "focal_length_x" in camera_data:
21
+ width, height = camera_data["resolution"]["value"]
22
+ fx = camera_data["focal_length_x"]["value"]
23
+ fy = camera_data["focal_length_y"]["value"]
24
+ cx = camera_data["center_x"]["value"]
25
+ cy = camera_data["center_y"]["value"]
26
+ else:
27
+ height, width = camera_data["resolution"]["value"]
28
+ f_mm = camera_data["focal_length"]["value"]
29
+ pixel_mm = camera_data["pixel_size"]["value"]
30
+ if pixel_mm == 0:
31
+ raise ValueError(
32
+ "pixel_size must be non-zero to derive focal length in pixels"
33
+ )
34
+ fx = fy = f_mm / pixel_mm
35
+ cx = width / 2.0
36
+ cy = height / 2.0
37
+ return Camera(
38
+ width=int(width),
39
+ height=int(height),
40
+ fx=float(fx),
41
+ fy=float(fy),
42
+ cx=float(cx),
43
+ cy=float(cy),
44
+ roll=float(camera_data["roll"]["value"]),
45
+ pitch=float(camera_data["pitch"]["value"]),
46
+ yaw=float(camera_data["yaw"]["value"]),
47
+ )
48
+
49
+ @classmethod
50
+ def from_json(cls, file_path: Union[str, Path]) -> "CalibrationData":
51
+ """Parse calibration data from a JSON file."""
52
+ with Path(file_path).open("r", encoding="utf-8") as f:
53
+ data = json.load(f)
54
+ cameras = {
55
+ camera_id: cls._build_camera(camera_data)
56
+ for camera_id, camera_data in data.get("cameras", {}).items()
57
+ }
58
+ return cls(cameras=cameras)
@@ -0,0 +1,265 @@
1
+ """FastAPI server for the Sentry homography calibration tool."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ import numpy as np
8
+ import uvicorn
9
+ from fastapi import FastAPI, HTTPException, Query
10
+ from fastapi.responses import HTMLResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from seavision.calibration import CalibrationData
14
+ from seavision.calibration.utils import (
15
+ CAMERA_PATTERNS,
16
+ DEFAULT_REFERENCE_CAMERA,
17
+ image_to_base64,
18
+ load_first_frames,
19
+ load_sentry_config,
20
+ process_calibration,
21
+ read_sensors_json,
22
+ source_cameras_for_reference,
23
+ update_sensors_json,
24
+ )
25
+
26
+ STATIC_DIR = Path(__file__).parent / "static"
27
+
28
+ app = FastAPI(title="Seavision")
29
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
30
+
31
+
32
+ @dataclass
33
+ class _ServerState:
34
+ """Mutable server state shared across request handlers."""
35
+
36
+ sentry: Optional[CalibrationData] = None
37
+ frames: Dict[str, np.ndarray] = field(default_factory=dict)
38
+ recording_path: Optional[str] = None
39
+ reference_camera: str = DEFAULT_REFERENCE_CAMERA
40
+ current_camera: Optional[str] = None
41
+ camera_ui_values: Dict[str, Dict[str, float]] = field(default_factory=dict)
42
+ dry_run: bool = False
43
+ recordings_root: Optional[Path] = None
44
+
45
+
46
+ _state = _ServerState()
47
+
48
+
49
+ def resolve_recording_path(path: str) -> Path:
50
+ """Resolve and validate a recording path against the configured root."""
51
+ resolved = Path(path).expanduser().resolve()
52
+ if _state.recordings_root is not None:
53
+ try:
54
+ resolved.relative_to(_state.recordings_root)
55
+ except ValueError as e:
56
+ raise ValueError("Access outside recordings root is not allowed") from e
57
+ return resolved
58
+
59
+
60
+ def _apply_recording(path: str) -> None:
61
+ """Load recording data into server state (atomic: commits only on full success)."""
62
+ resolved = resolve_recording_path(path)
63
+ sentry = load_sentry_config(str(resolved), reference_camera=_state.reference_camera)
64
+ frames = load_first_frames(str(resolved))
65
+ _state.sentry = sentry
66
+ _state.frames = frames
67
+ _state.recording_path = str(resolved)
68
+ _state.current_camera = None
69
+ _state.camera_ui_values = {}
70
+
71
+
72
+ @app.get("/", response_class=HTMLResponse)
73
+ def index() -> str:
74
+ """Serve the calibration UI."""
75
+ return (STATIC_DIR / "index.html").read_text()
76
+
77
+
78
+ @app.get("/api/camera_sources")
79
+ def camera_sources() -> Dict[str, Any]:
80
+ """List source cameras for the current reference (fixed at server start)."""
81
+ return {"cameras": source_cameras_for_reference(_state.reference_camera)}
82
+
83
+
84
+ @app.get("/api/select_camera_source")
85
+ def select_camera_source(camera: str = Query(...)) -> Dict[str, Any]:
86
+ """Select the active camera and return its current UI values."""
87
+ allowed = source_cameras_for_reference(_state.reference_camera)
88
+ if camera not in allowed:
89
+ raise HTTPException(400, f"Invalid camera: {camera}; allowed: {allowed}")
90
+ _state.current_camera = camera
91
+ if camera not in _state.camera_ui_values:
92
+ _state.camera_ui_values[camera] = (
93
+ _state.sentry.cameras[camera].extrinsics()
94
+ if _state.sentry is not None and camera in _state.sentry.cameras
95
+ else {"pitch": 0.0, "yaw": 0.0, "roll": 0.0}
96
+ )
97
+ return {"camera": camera, "values": _state.camera_ui_values[camera]}
98
+
99
+
100
+ @app.get("/api/process")
101
+ def process(
102
+ pitch: float = Query(0.0),
103
+ yaw: float = Query(0.0),
104
+ roll: float = Query(0.0),
105
+ weight: float = Query(0.5),
106
+ ) -> Dict[str, Any]:
107
+ """Run calibration and return base64-encoded result images."""
108
+ if _state.sentry is None or not _state.frames:
109
+ raise HTTPException(400, "No recording loaded")
110
+ if _state.current_camera is None:
111
+ raise HTTPException(400, "No camera source selected")
112
+ _state.camera_ui_values[_state.current_camera] = {
113
+ "pitch": pitch,
114
+ "yaw": yaw,
115
+ "roll": roll,
116
+ }
117
+ result = process_calibration(
118
+ _state.sentry,
119
+ _state.frames,
120
+ _state.current_camera,
121
+ pitch,
122
+ yaw,
123
+ roll,
124
+ weight,
125
+ reference_camera=_state.reference_camera,
126
+ )
127
+ return {"mixed": image_to_base64(result["mixed"])}
128
+
129
+
130
+ @app.get("/api/reset_camera")
131
+ def reset_camera(camera: str = Query(...)) -> Dict[str, Any]:
132
+ """Reset a camera's UI values to the original values from the recording."""
133
+ allowed = source_cameras_for_reference(_state.reference_camera)
134
+ if camera not in allowed:
135
+ raise HTTPException(400, f"Invalid camera: {camera}; allowed: {allowed}")
136
+ if _state.sentry is None:
137
+ raise HTTPException(400, "No recording loaded")
138
+ if camera not in _state.sentry.cameras:
139
+ raise HTTPException(400, f"Camera not found: {camera}")
140
+ _state.camera_ui_values[camera] = _state.sentry.cameras[camera].extrinsics()
141
+ return {"camera": camera, "values": _state.camera_ui_values[camera]}
142
+
143
+
144
+ @app.get("/api/load_recording")
145
+ def load_recording(path: str = Query(...)) -> Dict[str, Any]:
146
+ """Load a recording directory into server state."""
147
+ try:
148
+ _apply_recording(path)
149
+ except Exception as e:
150
+ raise HTTPException(400, str(e)) from e
151
+ return {"status": "loaded", "path": path}
152
+
153
+
154
+ @app.get("/api/browse_root")
155
+ def browse_root() -> Dict[str, Any]:
156
+ """Return the initial directory for the file browser."""
157
+ root = _state.recordings_root or Path("~").expanduser().resolve()
158
+ return {"path": str(root)}
159
+
160
+
161
+ @app.get("/api/browse")
162
+ def browse(path: str = Query(...)) -> Dict[str, Any]:
163
+ """List subdirectories of path, flagging valid recordings."""
164
+ base = Path(path).expanduser().resolve()
165
+ if not base.is_dir():
166
+ raise HTTPException(400, f"Not a directory: {path}")
167
+ if _state.recordings_root is not None:
168
+ try:
169
+ base.relative_to(_state.recordings_root)
170
+ except ValueError as e:
171
+ raise HTTPException(
172
+ 403, "Access outside recordings root is not allowed"
173
+ ) from e
174
+ entries = sorted(
175
+ (
176
+ {
177
+ "name": e.name,
178
+ "is_recording": (e / "POWER_Const" / "sentry_sensors.json").exists(),
179
+ }
180
+ for e in base.iterdir()
181
+ if e.is_dir()
182
+ ),
183
+ key=lambda e: (not e["is_recording"], e["name"]),
184
+ )
185
+ root = _state.recordings_root or base.anchor
186
+ return {
187
+ "path": str(base),
188
+ "parent": str(base.parent),
189
+ "root": str(root),
190
+ "entries": entries,
191
+ }
192
+
193
+
194
+ @app.get("/api/sensors_json")
195
+ def get_sensors_json() -> Dict[str, Any]:
196
+ """Return the current on-disk sentry_sensors.json and pending UI values."""
197
+ if _state.recording_path is None:
198
+ raise HTTPException(400, "No recording loaded")
199
+ return {
200
+ "data": read_sensors_json(_state.recording_path),
201
+ "pending": _state.camera_ui_values,
202
+ }
203
+
204
+
205
+ @app.get("/api/save_sensors_json")
206
+ def save_sensors_json() -> Dict[str, str]:
207
+ """Write pending UI values to sentry_sensors.json."""
208
+ if _state.recording_path is None:
209
+ raise HTTPException(400, "No recording loaded")
210
+ if _state.dry_run:
211
+ for camera, values in _state.camera_ui_values.items():
212
+ print(
213
+ f"[dry-run] {camera}: "
214
+ f"pitch={values['pitch']:.4f} "
215
+ f"yaw={values['yaw']:.4f} "
216
+ f"roll={values['roll']:.4f}"
217
+ )
218
+ else:
219
+ update_sensors_json(_state.recording_path, _state.camera_ui_values)
220
+ return {"status": "dry-run (see terminal)" if _state.dry_run else "saved"}
221
+
222
+
223
+ def configure_server(
224
+ recording_path: Optional[str] = None,
225
+ dry_run: bool = False,
226
+ reference_camera: Optional[str] = None,
227
+ recordings_root: Optional[str] = None,
228
+ ) -> None:
229
+ """Apply startup configuration to server state. Raises SystemExit for bad inputs."""
230
+ _state.dry_run = dry_run
231
+ if recordings_root is not None:
232
+ _state.recordings_root = Path(recordings_root).expanduser().resolve()
233
+ if reference_camera is not None:
234
+ if reference_camera not in CAMERA_PATTERNS:
235
+ raise SystemExit(
236
+ f"calibration: unknown reference {reference_camera!r}; "
237
+ f"expected one of: {sorted(CAMERA_PATTERNS)}"
238
+ )
239
+ _state.reference_camera = reference_camera
240
+ if recording_path is not None:
241
+ _apply_recording(recording_path)
242
+ print(f"Loaded recording: {recording_path}")
243
+ print(f"Reference (master) camera: {_state.reference_camera}")
244
+
245
+
246
+ def run_web_server(
247
+ recording_path: Optional[str] = None,
248
+ port: int = 8323,
249
+ host: str = "0.0.0.0",
250
+ dry_run: bool = False,
251
+ reference_camera: Optional[str] = None,
252
+ recordings_root: Optional[str] = None,
253
+ ) -> None:
254
+ """Start the calibration web server, optionally with a pre-loaded recording."""
255
+ configure_server(
256
+ recording_path=recording_path,
257
+ dry_run=dry_run,
258
+ reference_camera=reference_camera,
259
+ recordings_root=recordings_root,
260
+ )
261
+ open_url = (
262
+ f"http://127.0.0.1:{port}" if host == "0.0.0.0" else f"http://{host}:{port}"
263
+ )
264
+ print(f"Calibration server listening on {host}:{port} — open {open_url}")
265
+ uvicorn.run(app, host=host, port=port, log_level="warning")
@@ -0,0 +1,161 @@
1
+ /* SEA.AI design tokens — DARK (default) + LIGHT */
2
+
3
+ :root {
4
+ /* Neutral surfaces */
5
+ --surface-neutral-1: #101214;
6
+ --surface-neutral-2: #181b1e;
7
+ --surface-neutral-3: #202428;
8
+ --surface-neutral-4: #292e33;
9
+ --surface-neutral-5: #31373d;
10
+
11
+ /* Semantic surfaces */
12
+ --surface-primary-3: #0a67c2;
13
+ --surface-danger-3: #c20a20;
14
+ --surface-warning-3: #f1b80d;
15
+ --surface-success-3: #2b9e6e;
16
+
17
+ /* Content on neutral backgrounds */
18
+ --text-1: #e8eaed;
19
+ --text-2: #8a919a;
20
+ --text-3: #565e69;
21
+
22
+ /* Content on colored surfaces */
23
+ --content-primary-2: #cfe5fc;
24
+ --content-danger-1: #fa9ea9;
25
+ --content-warning-1: #fae39e;
26
+ --content-success-1: #a7f3d0;
27
+
28
+ /* Focus / accent */
29
+ --accent-primary-2: #0a67c2;
30
+ --accent-danger-2: #c20a20;
31
+ --stroke-normal: 1.5px;
32
+
33
+ /* JSON syntax (neutral semantics: structure vs data) */
34
+ --json-string: #8ecf8a;
35
+ --json-number: #c5a9f5;
36
+
37
+ /* Shape */
38
+ --radius-s: 4px;
39
+ --radius-m: 8px;
40
+ --radius-l: 16px;
41
+
42
+ /* Spacing */
43
+ --space-component-xs: 4px;
44
+ --space-component-s: 8px;
45
+ --space-component-m: 12px;
46
+ --space-component-l: 16px;
47
+ --space-component-2xl: 24px;
48
+ --space-component-3xl: 32px;
49
+
50
+ /* Legacy aliases (components, inline) */
51
+ --surface-1: var(--surface-neutral-1);
52
+ --surface-2: var(--surface-neutral-2);
53
+ --surface-3: var(--surface-neutral-3);
54
+ --surface-4: var(--surface-neutral-4);
55
+ --surface-5: var(--surface-neutral-5);
56
+ --primary: var(--surface-primary-3);
57
+ --primary-hover: #0856a3;
58
+ --danger: var(--surface-danger-3);
59
+ --warning: var(--surface-warning-3);
60
+ --success: var(--surface-success-3);
61
+ --content-primary: var(--content-primary-2);
62
+ --content-danger: var(--content-danger-1);
63
+ --content-warning: var(--content-warning-1);
64
+ --content-success: var(--content-success-1);
65
+ --space-xs: var(--space-component-xs);
66
+ --space-s: var(--space-component-s);
67
+ --space-m: var(--space-component-m);
68
+ --space-l: var(--space-component-l);
69
+ --space-2xl: var(--space-component-2xl);
70
+ --space-3xl: var(--space-component-3xl);
71
+ --button-height: 48px;
72
+ --input-height: 40px;
73
+ --theme-button-height: 30px;
74
+ }
75
+
76
+ [data-theme="light"] {
77
+ --surface-neutral-1: #b9bdc1;
78
+ --surface-neutral-2: #cbced1;
79
+ --surface-neutral-3: #dcdee0;
80
+ --surface-neutral-4: #eeeff0;
81
+ --surface-neutral-5: #ffffff;
82
+
83
+ --surface-primary-3: #0a67c2;
84
+ --surface-danger-3: #c20a20;
85
+ --surface-warning-3: #f1b80d;
86
+ --surface-success-3: #1a7a4f;
87
+
88
+ --text-1: #101214;
89
+ --text-2: #31373d;
90
+ --text-3: #565e69;
91
+
92
+ /* On colored surfaces: same as DARK per design spec */
93
+ --content-primary-2: #cfe5fc;
94
+ --content-danger-1: #fa9ea9;
95
+ --content-warning-1: #fae39e;
96
+ --content-success-1: #a7f3d0;
97
+
98
+ --json-string: #2d7a3a;
99
+ --json-number: #7b5ab5;
100
+ }
101
+
102
+ *,
103
+ *::before,
104
+ *::after {
105
+ box-sizing: border-box;
106
+ margin: 0;
107
+ padding: 0;
108
+ }
109
+
110
+ /* Typography — Saira Condensed; card-* uppercase for chrome */
111
+ body {
112
+ font-family: "Saira Condensed", "Arial Narrow", Arial, sans-serif;
113
+ background: var(--surface-neutral-3);
114
+ color: var(--text-1);
115
+ min-height: 100vh;
116
+ -webkit-font-smoothing: antialiased;
117
+ }
118
+
119
+ .card-label-s {
120
+ font-weight: 500;
121
+ font-size: 16px;
122
+ line-height: 24px;
123
+ letter-spacing: 0;
124
+ text-transform: uppercase;
125
+ }
126
+
127
+ .card-title-s {
128
+ font-weight: 400;
129
+ font-size: 20px;
130
+ line-height: 28px;
131
+ letter-spacing: 0.02em;
132
+ text-transform: uppercase;
133
+ }
134
+
135
+ .main-body-xs {
136
+ font-weight: 400;
137
+ font-size: 12px;
138
+ line-height: 20px;
139
+ letter-spacing: 0;
140
+ }
141
+
142
+ .element-button-m {
143
+ font-weight: 500;
144
+ font-size: 16px;
145
+ line-height: 24px;
146
+ letter-spacing: 0.015em;
147
+ }
148
+
149
+ ::-webkit-scrollbar {
150
+ width: 4px;
151
+ }
152
+ ::-webkit-scrollbar-track {
153
+ background: transparent;
154
+ }
155
+ ::-webkit-scrollbar-thumb {
156
+ background: var(--surface-neutral-5);
157
+ border-radius: 2px;
158
+ }
159
+ ::-webkit-scrollbar-thumb:hover {
160
+ background: var(--text-3);
161
+ }