+219
-274
@@ -1,7 +1,10 @@
|
|||||||
"""Batch synthetic sonar dataset generator for PointNet semantic segmentation.
|
"""Batch synthetic sonar dataset generator for PointNet semantic segmentation.
|
||||||
|
|
||||||
Produces paired Area_X_scene_XXXX.npy + .obj files under sonar_dataset/.
|
Produces paired Area_X_scene_XXXX.npy + .obj files under sonar_dataset/.
|
||||||
Target class 1 = user-provided object (from .obj mesh vertices); class 0 = seafloor / clutter.
|
Each scene point is one echosounder return: for every beam×ping ray the first
|
||||||
|
hit (object or seafloor) is recorded; denser object returns come only from
|
||||||
|
sonar resolution and geometry, not from overlaying a second cloud.
|
||||||
|
Target class 1 = user-provided object; class 0 = seafloor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,12 +18,7 @@ from scene_generator import (
|
|||||||
apply_transform,
|
apply_transform,
|
||||||
export_npy_float64,
|
export_npy_float64,
|
||||||
export_obj,
|
export_obj,
|
||||||
generate_box,
|
|
||||||
generate_pipe,
|
|
||||||
generate_sphere,
|
|
||||||
generate_torus,
|
|
||||||
parse_obj_points,
|
parse_obj_points,
|
||||||
points_to_pointnet_rows,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full dataset layout (train / val / test).
|
# Full dataset layout (train / val / test).
|
||||||
@@ -84,35 +82,6 @@ def load_object_points_from_obj_text(text: str) -> list[list[float]]:
|
|||||||
return normalize_object_points(points)
|
return normalize_object_points(points)
|
||||||
|
|
||||||
|
|
||||||
def resample_object_points(
|
|
||||||
template: list[list[float]],
|
|
||||||
count: int,
|
|
||||||
*,
|
|
||||||
noise: float = 0.0,
|
|
||||||
seed: int = 1,
|
|
||||||
) -> list[list[float]]:
|
|
||||||
"""Subsample (or sample with replacement) template points to the requested count."""
|
|
||||||
if not template:
|
|
||||||
raise ValueError("Object template is empty.")
|
|
||||||
rng = random.Random(int(seed))
|
|
||||||
count = max(1, int(count))
|
|
||||||
out: list[list[float]] = []
|
|
||||||
n = len(template)
|
|
||||||
for _ in range(count):
|
|
||||||
src = template[rng.randrange(n)]
|
|
||||||
if noise > 0:
|
|
||||||
out.append(
|
|
||||||
[
|
|
||||||
src[0] + rng.uniform(-noise, noise),
|
|
||||||
src[1] + rng.uniform(-noise, noise),
|
|
||||||
src[2] + rng.uniform(-noise, noise),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
out.append([src[0], src[1], src[2]])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def object_half_extent_z(points: list[list[float]]) -> float:
|
def object_half_extent_z(points: list[list[float]]) -> float:
|
||||||
if not points:
|
if not points:
|
||||||
return 0.35
|
return 0.35
|
||||||
@@ -121,7 +90,7 @@ def object_half_extent_z(points: list[list[float]]) -> float:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Seafloor / clutter
|
# Seafloor heightfield + echosounder ray casting
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _seafloor_height(
|
def _seafloor_height(
|
||||||
@@ -153,17 +122,13 @@ def _seafloor_height(
|
|||||||
return z
|
return z
|
||||||
|
|
||||||
|
|
||||||
def _generate_seafloor(
|
def _build_seafloor_meta(
|
||||||
rng: random.Random,
|
rng: random.Random,
|
||||||
*,
|
*,
|
||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
length_count: int | None = None,
|
||||||
) -> tuple[list[list[float]], dict[str, Any]]:
|
) -> dict[str, Any]:
|
||||||
"""Sample seafloor as a square relief grid.
|
"""Build continuous seafloor heightfield parameters (no point cloud yet)."""
|
||||||
|
|
||||||
beam_count controls width resolution (X axis).
|
|
||||||
length_count controls length resolution (Y axis).
|
|
||||||
"""
|
|
||||||
beams = max(1, int(beam_count))
|
beams = max(1, int(beam_count))
|
||||||
length_points = beams if length_count is None else max(1, int(length_count))
|
length_points = beams if length_count is None else max(1, int(length_count))
|
||||||
size_x = rng.uniform(8.0, 16.0)
|
size_x = rng.uniform(8.0, 16.0)
|
||||||
@@ -171,7 +136,6 @@ def _generate_seafloor(
|
|||||||
base_z = rng.uniform(-1.2, -0.2)
|
base_z = rng.uniform(-1.2, -0.2)
|
||||||
amplitude = rng.uniform(0.05, 0.35)
|
amplitude = rng.uniform(0.05, 0.35)
|
||||||
frequency = rng.uniform(0.4, 2.2)
|
frequency = rng.uniform(0.4, 2.2)
|
||||||
noise = rng.uniform(0.005, 0.04)
|
|
||||||
|
|
||||||
hills = [
|
hills = [
|
||||||
(
|
(
|
||||||
@@ -191,17 +155,18 @@ def _generate_seafloor(
|
|||||||
)
|
)
|
||||||
for _ in range(rng.randint(1, 3))
|
for _ in range(rng.randint(1, 3))
|
||||||
]
|
]
|
||||||
|
# Relief clutter / false features as heightfield bumps (not extra points)
|
||||||
bumps = [
|
bumps = [
|
||||||
(
|
(
|
||||||
rng.uniform(-size_x * 0.45, size_x * 0.45),
|
rng.uniform(-size_x * 0.45, size_x * 0.45),
|
||||||
rng.uniform(-size_y * 0.45, size_y * 0.45),
|
rng.uniform(-size_y * 0.45, size_y * 0.45),
|
||||||
rng.uniform(0.03, 0.18),
|
rng.uniform(0.03, 0.35),
|
||||||
rng.uniform(0.15, 0.55),
|
rng.uniform(0.15, 0.9),
|
||||||
)
|
)
|
||||||
for _ in range(rng.randint(3, 12))
|
for _ in range(rng.randint(4, 14))
|
||||||
]
|
]
|
||||||
|
|
||||||
meta = {
|
return {
|
||||||
"sizeX": size_x,
|
"sizeX": size_x,
|
||||||
"sizeY": size_y,
|
"sizeY": size_y,
|
||||||
"baseZ": base_z,
|
"baseZ": base_z,
|
||||||
@@ -214,59 +179,11 @@ def _generate_seafloor(
|
|||||||
"lengthCount": length_points,
|
"lengthCount": length_points,
|
||||||
"gridWidthPoints": beams,
|
"gridWidthPoints": beams,
|
||||||
"gridLengthPoints": length_points,
|
"gridLengthPoints": length_points,
|
||||||
|
"pingCount": length_points,
|
||||||
|
"swathBeams": beams,
|
||||||
|
"noise": rng.uniform(0.002, 0.02),
|
||||||
}
|
}
|
||||||
|
|
||||||
half_x = size_x * 0.5
|
|
||||||
half_y = size_y * 0.5
|
|
||||||
points: list[list[float]] = []
|
|
||||||
for yi in range(length_points):
|
|
||||||
y = -half_y if length_points == 1 else (-half_y + size_y * yi / (length_points - 1))
|
|
||||||
for xi in range(beams):
|
|
||||||
x = -half_x if beams == 1 else (-half_x + size_x * xi / (beams - 1))
|
|
||||||
x += rng.uniform(-noise * 2, noise * 2)
|
|
||||||
yj = y + rng.uniform(-noise * 2, noise * 2)
|
|
||||||
z = _seafloor_height(
|
|
||||||
x,
|
|
||||||
yj,
|
|
||||||
base_z=base_z,
|
|
||||||
amplitude=amplitude,
|
|
||||||
frequency=frequency,
|
|
||||||
hills=hills,
|
|
||||||
valleys=valleys,
|
|
||||||
bumps=bumps,
|
|
||||||
)
|
|
||||||
z += rng.uniform(-noise, noise)
|
|
||||||
points.append([x, yj, z])
|
|
||||||
|
|
||||||
# Local noise clusters (false sonar clutter blobs)
|
|
||||||
for _ in range(rng.randint(1, 5)):
|
|
||||||
cx = rng.uniform(-half_x * 0.8, half_x * 0.8)
|
|
||||||
cy = rng.uniform(-half_y * 0.8, half_y * 0.8)
|
|
||||||
cz = _seafloor_height(
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
base_z=base_z,
|
|
||||||
amplitude=amplitude,
|
|
||||||
frequency=frequency,
|
|
||||||
hills=hills,
|
|
||||||
valleys=valleys,
|
|
||||||
bumps=bumps,
|
|
||||||
) + rng.uniform(0.0, 0.25)
|
|
||||||
n_blob = rng.randint(40, 280)
|
|
||||||
spread = rng.uniform(0.15, 0.7)
|
|
||||||
for _ in range(n_blob):
|
|
||||||
points.append(
|
|
||||||
[
|
|
||||||
cx + rng.gauss(0, spread),
|
|
||||||
cy + rng.gauss(0, spread),
|
|
||||||
cz + rng.gauss(0, spread * 0.35),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
meta["pingCount"] = length_points
|
|
||||||
meta["swathBeams"] = beams
|
|
||||||
return points, meta
|
|
||||||
|
|
||||||
|
|
||||||
def _height_at(x: float, y: float, meta: dict[str, Any]) -> float:
|
def _height_at(x: float, y: float, meta: dict[str, Any]) -> float:
|
||||||
return _seafloor_height(
|
return _seafloor_height(
|
||||||
@@ -281,76 +198,167 @@ def _height_at(x: float, y: float, meta: dict[str, Any]) -> float:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_false_objects(rng: random.Random, meta: dict[str, Any]) -> list[list[float]]:
|
def _grid_xy(xi: int, yi: int, meta: dict[str, Any]) -> tuple[float, float]:
|
||||||
n_objects = rng.randint(0, 6)
|
beams = int(meta["beamCount"])
|
||||||
points: list[list[float]] = []
|
length_points = int(meta["lengthCount"])
|
||||||
half_x = float(meta["sizeX"]) * 0.5
|
size_x = float(meta["sizeX"])
|
||||||
half_y = float(meta["sizeY"]) * 0.5
|
size_y = float(meta["sizeY"])
|
||||||
|
half_x = size_x * 0.5
|
||||||
for i in range(n_objects):
|
half_y = size_y * 0.5
|
||||||
kind = rng.choice(["sphere", "box", "torus", "pipe"])
|
x = -half_x if beams == 1 else (-half_x + size_x * xi / (beams - 1))
|
||||||
count = rng.randint(80, 900)
|
y = -half_y if length_points == 1 else (-half_y + size_y * yi / (length_points - 1))
|
||||||
noise = rng.uniform(0.005, 0.03)
|
return x, y
|
||||||
seed = rng.randint(0, 10_000_000)
|
|
||||||
if kind == "sphere":
|
|
||||||
local = generate_sphere(
|
|
||||||
{"radius": rng.uniform(0.08, 0.55), "count": count, "noise": noise, "seed": seed}
|
|
||||||
)
|
|
||||||
elif kind == "box":
|
|
||||||
local = generate_box(
|
|
||||||
{
|
|
||||||
"sizeX": rng.uniform(0.15, 1.2),
|
|
||||||
"sizeY": rng.uniform(0.15, 1.0),
|
|
||||||
"sizeZ": rng.uniform(0.08, 0.6),
|
|
||||||
"count": count,
|
|
||||||
"noise": noise,
|
|
||||||
"seed": seed,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif kind == "torus":
|
|
||||||
major = rng.uniform(0.15, 0.6)
|
|
||||||
local = generate_torus(
|
|
||||||
{
|
|
||||||
"majorR": major,
|
|
||||||
"minorR": rng.uniform(0.03, major * 0.4),
|
|
||||||
"count": count,
|
|
||||||
"noise": noise,
|
|
||||||
"seed": seed,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
local = generate_pipe(
|
|
||||||
{
|
|
||||||
"length": rng.uniform(0.4, 2.5),
|
|
||||||
"radius": rng.uniform(0.04, 0.2),
|
|
||||||
"axis": rng.choice(["x", "y", "z"]),
|
|
||||||
"count": count,
|
|
||||||
"noise": noise,
|
|
||||||
"seed": seed,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
tx = rng.uniform(-half_x * 0.75, half_x * 0.75)
|
|
||||||
ty = rng.uniform(-half_y * 0.75, half_y * 0.75)
|
|
||||||
floor_z = _height_at(tx, ty, meta)
|
|
||||||
# Rest on / slightly into seafloor
|
|
||||||
tz = floor_z + rng.uniform(-0.05, 0.35)
|
|
||||||
transform = {
|
|
||||||
"x": tx,
|
|
||||||
"y": ty,
|
|
||||||
"z": tz,
|
|
||||||
"rx": rng.uniform(-0.4, 0.4),
|
|
||||||
"ry": rng.uniform(-0.4, 0.4),
|
|
||||||
"rz": rng.uniform(0, 2 * math.pi),
|
|
||||||
}
|
|
||||||
world = apply_transform(local, transform)
|
|
||||||
# Drop points buried deep under seafloor
|
|
||||||
for p in world:
|
|
||||||
if p[2] >= _height_at(p[0], p[1], meta) - 0.02:
|
|
||||||
points.append(p)
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_size(meta: dict[str, Any]) -> tuple[float, float]:
|
||||||
|
beams = int(meta["beamCount"])
|
||||||
|
length_points = int(meta["lengthCount"])
|
||||||
|
size_x = float(meta["sizeX"])
|
||||||
|
size_y = float(meta["sizeY"])
|
||||||
|
return size_x / max(beams - 1, 1), size_y / max(length_points - 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _world_to_grid_index(
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
beams = int(meta["beamCount"])
|
||||||
|
length_points = int(meta["lengthCount"])
|
||||||
|
size_x = float(meta["sizeX"])
|
||||||
|
size_y = float(meta["sizeY"])
|
||||||
|
half_x = size_x * 0.5
|
||||||
|
half_y = size_y * 0.5
|
||||||
|
fx = 0.0 if beams == 1 else (x + half_x) / size_x * (beams - 1)
|
||||||
|
fy = 0.0 if length_points == 1 else (y + half_y) / size_y * (length_points - 1)
|
||||||
|
return fx, fy
|
||||||
|
|
||||||
|
|
||||||
|
def _place_object_in_scene(
|
||||||
|
rng: random.Random,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
visibility: str,
|
||||||
|
object_template: list[list[float]],
|
||||||
|
object_scale: float = 1.0,
|
||||||
|
) -> tuple[list[list[float]], dict[str, Any]]:
|
||||||
|
"""Pose the object on the seafloor; return world-space template vertices + info."""
|
||||||
|
base_scale = max(0.01, float(object_scale))
|
||||||
|
if visibility == "nearly_hidden":
|
||||||
|
burial = rng.uniform(0.35, 0.75)
|
||||||
|
scale = base_scale * rng.uniform(0.7, 1.15)
|
||||||
|
elif visibility == "partial":
|
||||||
|
burial = rng.uniform(0.12, 0.4)
|
||||||
|
scale = base_scale * rng.uniform(0.8, 1.3)
|
||||||
|
else:
|
||||||
|
burial = rng.uniform(-0.05, 0.15)
|
||||||
|
scale = base_scale * rng.uniform(0.85, 1.4)
|
||||||
|
|
||||||
|
local = [[p[0] * scale, p[1] * scale, p[2] * scale] for p in object_template]
|
||||||
|
half_x = float(meta["sizeX"]) * 0.35
|
||||||
|
half_y = float(meta["sizeY"]) * 0.35
|
||||||
|
tx = rng.uniform(-half_x, half_x)
|
||||||
|
ty = rng.uniform(-half_y, half_y)
|
||||||
|
floor_z = _height_at(tx, ty, meta)
|
||||||
|
half_h = object_half_extent_z(local)
|
||||||
|
tz = floor_z + half_h * (1.0 - 2.0 * burial)
|
||||||
|
transform = {
|
||||||
|
"x": tx,
|
||||||
|
"y": ty,
|
||||||
|
"z": tz,
|
||||||
|
"rx": rng.uniform(-0.25, 0.25),
|
||||||
|
"ry": rng.uniform(-0.2, 0.2),
|
||||||
|
"rz": rng.uniform(0, 2 * math.pi),
|
||||||
|
}
|
||||||
|
world = apply_transform(local, transform)
|
||||||
|
info = {
|
||||||
|
"visibility": visibility,
|
||||||
|
"transform": transform,
|
||||||
|
"scale": scale,
|
||||||
|
"objectScale": base_scale,
|
||||||
|
"burial": burial,
|
||||||
|
"vertexCount": len(world),
|
||||||
|
"classLabel": "object",
|
||||||
|
"classId": 1,
|
||||||
|
}
|
||||||
|
return world, info
|
||||||
|
|
||||||
|
|
||||||
|
def _rasterize_object_hits(
|
||||||
|
world_pts: list[list[float]],
|
||||||
|
meta: dict[str, Any],
|
||||||
|
) -> dict[tuple[int, int], float]:
|
||||||
|
"""Project object vertices onto the sonar grid: first-hit Z per beam×ping cell.
|
||||||
|
|
||||||
|
Sensor looks down (+Z is closer). A cell stores the highest object Z that
|
||||||
|
falls into its footprint, so later casting can compare against seafloor Z.
|
||||||
|
"""
|
||||||
|
beams = int(meta["beamCount"])
|
||||||
|
length_points = int(meta["lengthCount"])
|
||||||
|
cell_w, cell_h = _cell_size(meta)
|
||||||
|
# Cover gaps between sparse mesh vertices across neighboring cells
|
||||||
|
splat_rx = cell_w * 0.85
|
||||||
|
splat_ry = cell_h * 0.85
|
||||||
|
hits: dict[tuple[int, int], float] = {}
|
||||||
|
|
||||||
|
for px, py, pz in world_pts:
|
||||||
|
fx, fy = _world_to_grid_index(px, py, meta)
|
||||||
|
xi0 = int(math.floor(fx))
|
||||||
|
yi0 = int(math.floor(fy))
|
||||||
|
for dyi in (-1, 0, 1, 2):
|
||||||
|
for dxi in (-1, 0, 1, 2):
|
||||||
|
xi = xi0 + dxi
|
||||||
|
yi = yi0 + dyi
|
||||||
|
if xi < 0 or yi < 0 or xi >= beams or yi >= length_points:
|
||||||
|
continue
|
||||||
|
cx, cy = _grid_xy(xi, yi, meta)
|
||||||
|
if abs(px - cx) > splat_rx or abs(py - cy) > splat_ry:
|
||||||
|
continue
|
||||||
|
floor_z = _height_at(cx, cy, meta)
|
||||||
|
# Buried volume does not return a sonar echo above the seafloor
|
||||||
|
if pz < floor_z - 0.01:
|
||||||
|
continue
|
||||||
|
key = (xi, yi)
|
||||||
|
prev = hits.get(key)
|
||||||
|
if prev is None or pz > prev:
|
||||||
|
hits[key] = pz
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def _cast_echosounder_returns(
|
||||||
|
rng: random.Random,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
object_hits: dict[tuple[int, int], float] | None,
|
||||||
|
) -> tuple[list[list[float]], int, int]:
|
||||||
|
"""One return per ray: first surface hit from above (object or seafloor).
|
||||||
|
|
||||||
|
Returns PointNet rows [x,y,z,r,g,b,class], object_count, background_count.
|
||||||
|
"""
|
||||||
|
beams = int(meta["beamCount"])
|
||||||
|
length_points = int(meta["lengthCount"])
|
||||||
|
noise = float(meta.get("noise", 0.01))
|
||||||
|
rows: list[list[float]] = []
|
||||||
|
object_count = 0
|
||||||
|
background_count = 0
|
||||||
|
|
||||||
|
for yi in range(length_points):
|
||||||
|
for xi in range(beams):
|
||||||
|
x, y = _grid_xy(xi, yi, meta)
|
||||||
|
floor_z = _height_at(x, y, meta)
|
||||||
|
obj_z = object_hits.get((xi, yi)) if object_hits else None
|
||||||
|
|
||||||
|
# Looking down: larger Z is closer → first intersection wins.
|
||||||
|
if obj_z is not None and obj_z > floor_z:
|
||||||
|
z = obj_z + rng.uniform(-noise, noise)
|
||||||
|
cls = 1.0
|
||||||
|
object_count += 1
|
||||||
|
else:
|
||||||
|
z = floor_z + rng.uniform(-noise, noise)
|
||||||
|
cls = 0.0
|
||||||
|
background_count += 1
|
||||||
|
|
||||||
|
rows.append([float(x), float(y), float(z), 0.0, 0.0, 0.0, cls])
|
||||||
|
|
||||||
|
return rows, object_count, background_count
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -373,82 +381,6 @@ def plan_scene_labels(count: int, seed: int) -> list[str]:
|
|||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
def _place_object(
|
|
||||||
rng: random.Random,
|
|
||||||
meta: dict[str, Any],
|
|
||||||
visibility: str,
|
|
||||||
object_template: list[list[float]],
|
|
||||||
object_scale: float = 1.0,
|
|
||||||
) -> tuple[list[list[float]], dict[str, Any]]:
|
|
||||||
"""Sample, transform, and bury target object; return surviving world points + info."""
|
|
||||||
base_scale = max(0.01, float(object_scale))
|
|
||||||
if visibility == "nearly_hidden":
|
|
||||||
count = rng.randint(80, 600)
|
|
||||||
burial = rng.uniform(0.35, 0.75)
|
|
||||||
scale = base_scale * rng.uniform(0.7, 1.15)
|
|
||||||
elif visibility == "partial":
|
|
||||||
count = rng.randint(400, 2500)
|
|
||||||
burial = rng.uniform(0.12, 0.4)
|
|
||||||
scale = base_scale * rng.uniform(0.8, 1.3)
|
|
||||||
else: # visible
|
|
||||||
count = rng.randint(1500, 8000)
|
|
||||||
burial = rng.uniform(-0.05, 0.15)
|
|
||||||
scale = base_scale * rng.uniform(0.85, 1.4)
|
|
||||||
|
|
||||||
noise = rng.uniform(0.004, 0.025)
|
|
||||||
local = resample_object_points(
|
|
||||||
object_template,
|
|
||||||
count,
|
|
||||||
noise=noise,
|
|
||||||
seed=rng.randint(0, 10_000_000),
|
|
||||||
)
|
|
||||||
# Apply world scale to unit-normalized template
|
|
||||||
local = [[p[0] * scale, p[1] * scale, p[2] * scale] for p in local]
|
|
||||||
|
|
||||||
half_x = float(meta["sizeX"]) * 0.35
|
|
||||||
half_y = float(meta["sizeY"]) * 0.35
|
|
||||||
tx = rng.uniform(-half_x, half_x)
|
|
||||||
ty = rng.uniform(-half_y, half_y)
|
|
||||||
floor_z = _height_at(tx, ty, meta)
|
|
||||||
|
|
||||||
half_h = object_half_extent_z(local)
|
|
||||||
tz = floor_z + half_h * (1.0 - 2.0 * burial)
|
|
||||||
|
|
||||||
transform = {
|
|
||||||
"x": tx,
|
|
||||||
"y": ty,
|
|
||||||
"z": tz,
|
|
||||||
"rx": rng.uniform(-0.25, 0.25),
|
|
||||||
"ry": rng.uniform(-0.2, 0.2),
|
|
||||||
"rz": rng.uniform(0, 2 * math.pi),
|
|
||||||
}
|
|
||||||
world = apply_transform(local, transform)
|
|
||||||
|
|
||||||
kept: list[list[float]] = []
|
|
||||||
for p in world:
|
|
||||||
surface = _height_at(p[0], p[1], meta)
|
|
||||||
eps = 0.01 if visibility != "nearly_hidden" else -0.02
|
|
||||||
if p[2] >= surface + eps:
|
|
||||||
kept.append(p)
|
|
||||||
|
|
||||||
if visibility == "nearly_hidden" and len(kept) < 15 and world:
|
|
||||||
ranked = sorted(world, key=lambda p: p[2] - _height_at(p[0], p[1], meta), reverse=True)
|
|
||||||
kept = ranked[: max(15, min(40, len(ranked) // 8))]
|
|
||||||
|
|
||||||
info = {
|
|
||||||
"visibility": visibility,
|
|
||||||
"transform": transform,
|
|
||||||
"requestedCount": count,
|
|
||||||
"keptCount": len(kept),
|
|
||||||
"scale": scale,
|
|
||||||
"objectScale": base_scale,
|
|
||||||
"burial": burial,
|
|
||||||
"classLabel": "object",
|
|
||||||
"classId": 1,
|
|
||||||
}
|
|
||||||
return kept, info
|
|
||||||
|
|
||||||
|
|
||||||
def generate_sonar_scene(
|
def generate_sonar_scene(
|
||||||
*,
|
*,
|
||||||
seed: int,
|
seed: int,
|
||||||
@@ -458,48 +390,42 @@ def generate_sonar_scene(
|
|||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
length_count: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build one unique sonar scene. visibility in absent|nearly_hidden|partial|visible."""
|
"""Build one sonar scene via beam×ping first-hit casting.
|
||||||
|
|
||||||
|
visibility in absent|nearly_hidden|partial|visible.
|
||||||
|
Scene size is always beam_count × length_count returns.
|
||||||
|
"""
|
||||||
rng = random.Random(int(seed))
|
rng = random.Random(int(seed))
|
||||||
if visibility not in ("absent",) + VISIBILITY_TIERS:
|
if visibility not in ("absent",) + VISIBILITY_TIERS:
|
||||||
raise ValueError(f"Unknown visibility: {visibility}")
|
raise ValueError(f"Unknown visibility: {visibility}")
|
||||||
if visibility != "absent" and not object_points:
|
if visibility != "absent" and not object_points:
|
||||||
raise ValueError("object_points required when visibility is not absent.")
|
raise ValueError("object_points required when visibility is not absent.")
|
||||||
|
|
||||||
floor_pts, meta = _generate_seafloor(rng, beam_count=beam_count, length_count=length_count)
|
meta = _build_seafloor_meta(rng, beam_count=beam_count, length_count=length_count)
|
||||||
clutter = _generate_false_objects(rng, meta)
|
expected = int(meta["beamCount"]) * int(meta["lengthCount"])
|
||||||
|
|
||||||
jitter = rng.uniform(0.0, 0.015)
|
|
||||||
background = floor_pts + clutter
|
|
||||||
if jitter > 0:
|
|
||||||
background = [
|
|
||||||
[
|
|
||||||
p[0] + rng.uniform(-jitter, jitter),
|
|
||||||
p[1] + rng.uniform(-jitter, jitter),
|
|
||||||
p[2] + rng.uniform(-jitter, jitter),
|
|
||||||
]
|
|
||||||
for p in background
|
|
||||||
]
|
|
||||||
|
|
||||||
drop = rng.uniform(0.0, 0.12)
|
|
||||||
if drop > 0:
|
|
||||||
background = [p for p in background if rng.random() >= drop]
|
|
||||||
|
|
||||||
object_pts: list[list[float]] = []
|
|
||||||
object_info: dict[str, Any] | None = None
|
object_info: dict[str, Any] | None = None
|
||||||
|
object_hits: dict[tuple[int, int], float] | None = None
|
||||||
if visibility != "absent":
|
if visibility != "absent":
|
||||||
object_pts, object_info = _place_object(
|
world, object_info = _place_object_in_scene(
|
||||||
rng,
|
rng,
|
||||||
meta,
|
meta,
|
||||||
visibility,
|
visibility,
|
||||||
object_points,
|
object_points,
|
||||||
object_scale=object_scale,
|
object_scale=object_scale,
|
||||||
)
|
)
|
||||||
|
object_hits = _rasterize_object_hits(world, meta)
|
||||||
|
object_info["hitCellCount"] = len(object_hits)
|
||||||
|
object_info["keptCount"] = len(object_hits)
|
||||||
|
object_info["requestedCount"] = expected
|
||||||
|
|
||||||
|
rows, object_point_count, background_point_count = _cast_echosounder_returns(
|
||||||
|
rng, meta, object_hits
|
||||||
|
)
|
||||||
|
if len(rows) != expected:
|
||||||
|
raise RuntimeError(f"Ray count mismatch: expected {expected}, got {len(rows)}")
|
||||||
|
|
||||||
# class 0 = background, class 1 = object
|
|
||||||
rows = points_to_pointnet_rows(background, 0.0)
|
|
||||||
rows.extend(points_to_pointnet_rows(object_pts, 1.0))
|
|
||||||
rng.shuffle(rows)
|
rng.shuffle(rows)
|
||||||
|
|
||||||
xyz = [[r[0], r[1], r[2]] for r in rows]
|
xyz = [[r[0], r[1], r[2]] for r in rows]
|
||||||
return {
|
return {
|
||||||
"seed": int(seed),
|
"seed": int(seed),
|
||||||
@@ -507,15 +433,18 @@ def generate_sonar_scene(
|
|||||||
"hasObject": visibility != "absent",
|
"hasObject": visibility != "absent",
|
||||||
"object": object_info,
|
"object": object_info,
|
||||||
"pointCount": len(rows),
|
"pointCount": len(rows),
|
||||||
"objectPointCount": len(object_pts),
|
"objectPointCount": object_point_count,
|
||||||
"backgroundPointCount": len(background),
|
"backgroundPointCount": background_point_count,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"points": xyz,
|
"points": xyz,
|
||||||
"meta": {
|
"meta": {
|
||||||
"sizeX": meta["sizeX"],
|
"sizeX": meta["sizeX"],
|
||||||
"sizeY": meta["sizeY"],
|
"sizeY": meta["sizeY"],
|
||||||
"beamCount": meta.get("beamCount", beam_count),
|
"beamCount": meta.get("beamCount", beam_count),
|
||||||
"lengthCount": meta.get("lengthCount", length_count if length_count is not None else beam_count),
|
"lengthCount": meta.get(
|
||||||
|
"lengthCount",
|
||||||
|
length_count if length_count is not None else beam_count,
|
||||||
|
),
|
||||||
"gridWidthPoints": meta.get("gridWidthPoints", beam_count),
|
"gridWidthPoints": meta.get("gridWidthPoints", beam_count),
|
||||||
"gridLengthPoints": meta.get(
|
"gridLengthPoints": meta.get(
|
||||||
"gridLengthPoints",
|
"gridLengthPoints",
|
||||||
@@ -523,6 +452,7 @@ def generate_sonar_scene(
|
|||||||
),
|
),
|
||||||
"pingCount": meta.get("pingCount"),
|
"pingCount": meta.get("pingCount"),
|
||||||
"swathBeams": meta.get("swathBeams"),
|
"swathBeams": meta.get("swathBeams"),
|
||||||
|
"gridPointCount": expected,
|
||||||
"floorFeatures": {
|
"floorFeatures": {
|
||||||
"hills": len(meta["hills"]),
|
"hills": len(meta["hills"]),
|
||||||
"valleys": len(meta["valleys"]),
|
"valleys": len(meta["valleys"]),
|
||||||
@@ -666,6 +596,7 @@ def generate_dataset(
|
|||||||
object_points: list[list[float]],
|
object_points: list[list[float]],
|
||||||
object_name: str | None = None,
|
object_name: str | None = None,
|
||||||
object_scale: float = 1.0,
|
object_scale: float = 1.0,
|
||||||
|
object_scale_is_max: bool = False,
|
||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
length_count: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -673,8 +604,11 @@ def generate_dataset(
|
|||||||
|
|
||||||
object_points: normalized template vertices from user .obj (class 1 = object).
|
object_points: normalized template vertices from user .obj (class 1 = object).
|
||||||
object_scale: relative size multiplier vs unit-normalized mesh (1.0 = default).
|
object_scale: relative size multiplier vs unit-normalized mesh (1.0 = default).
|
||||||
beam_count: number of width points for seafloor grid (X axis).
|
object_scale_is_max: if True, treat object_scale as upper bound and sample
|
||||||
length_count: number of length points for seafloor grid (Y axis). Defaults to beam_count.
|
per-scene scale uniformly from [1, object_scale] (AUV altitude variation).
|
||||||
|
beam_count: across-track beams (width resolution, X).
|
||||||
|
length_count: along-track pings (length resolution, Y). Defaults to beam_count.
|
||||||
|
Each scene has exactly beam_count × length_count sonar returns (first-hit casting).
|
||||||
"""
|
"""
|
||||||
count = int(count)
|
count = int(count)
|
||||||
if count < 1:
|
if count < 1:
|
||||||
@@ -688,6 +622,7 @@ def generate_dataset(
|
|||||||
raise ValueError("object_scale must be > 0")
|
raise ValueError("object_scale must be > 0")
|
||||||
if object_scale > 100:
|
if object_scale > 100:
|
||||||
raise ValueError("object_scale must be <= 100")
|
raise ValueError("object_scale must be <= 100")
|
||||||
|
object_scale_is_max = bool(object_scale_is_max)
|
||||||
beam_count = int(beam_count)
|
beam_count = int(beam_count)
|
||||||
if beam_count < 1:
|
if beam_count < 1:
|
||||||
raise ValueError("beam_count (Кол-во лучей) must be >= 1")
|
raise ValueError("beam_count (Кол-во лучей) must be >= 1")
|
||||||
@@ -723,11 +658,19 @@ def generate_dataset(
|
|||||||
for i in range(count):
|
for i in range(count):
|
||||||
visibility = labels[i]
|
visibility = labels[i]
|
||||||
scene_seed = int(seed) + i * 10007 + 17
|
scene_seed = int(seed) + i * 10007 + 17
|
||||||
|
scene_rng = random.Random(scene_seed ^ 0xC0FFEE)
|
||||||
|
if object_scale_is_max:
|
||||||
|
lo, hi = 1.0, object_scale
|
||||||
|
if hi < lo:
|
||||||
|
lo, hi = hi, lo
|
||||||
|
scene_scale = scene_rng.uniform(lo, hi)
|
||||||
|
else:
|
||||||
|
scene_scale = object_scale
|
||||||
scene = generate_sonar_scene(
|
scene = generate_sonar_scene(
|
||||||
seed=scene_seed,
|
seed=scene_seed,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
object_points=template,
|
object_points=template,
|
||||||
object_scale=object_scale,
|
object_scale=scene_scale,
|
||||||
beam_count=beam_count,
|
beam_count=beam_count,
|
||||||
length_count=length_count,
|
length_count=length_count,
|
||||||
)
|
)
|
||||||
@@ -743,6 +686,7 @@ def generate_dataset(
|
|||||||
"hasObject": scene["hasObject"],
|
"hasObject": scene["hasObject"],
|
||||||
"pointCount": scene["pointCount"],
|
"pointCount": scene["pointCount"],
|
||||||
"objectPointCount": scene["objectPointCount"],
|
"objectPointCount": scene["objectPointCount"],
|
||||||
|
"objectScale": scene_scale,
|
||||||
"files": paths,
|
"files": paths,
|
||||||
}
|
}
|
||||||
written.append(entry)
|
written.append(entry)
|
||||||
@@ -777,6 +721,7 @@ def generate_dataset(
|
|||||||
"lengthCount": length_count,
|
"lengthCount": length_count,
|
||||||
"objectName": object_name,
|
"objectName": object_name,
|
||||||
"objectScale": object_scale,
|
"objectScale": object_scale,
|
||||||
|
"objectScaleIsMax": object_scale_is_max,
|
||||||
"objectVertexCount": len(template),
|
"objectVertexCount": len(template),
|
||||||
"classLabels": {"0": "background", "1": "object"},
|
"classLabels": {"0": "background", "1": "object"},
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ async def dataset_generate(
|
|||||||
seed: int = Form(42),
|
seed: int = Form(42),
|
||||||
outputDir: str = Form("sonar_dataset"),
|
outputDir: str = Form("sonar_dataset"),
|
||||||
objectScale: float = Form(1.0),
|
objectScale: float = Form(1.0),
|
||||||
|
objectScaleIsMax: bool = Form(False),
|
||||||
beamCount: int = Form(45),
|
beamCount: int = Form(45),
|
||||||
lengthCount: int | None = Form(None),
|
lengthCount: int | None = Form(None),
|
||||||
model: UploadFile = File(...),
|
model: UploadFile = File(...),
|
||||||
@@ -443,6 +444,7 @@ async def dataset_generate(
|
|||||||
object_points=object_points,
|
object_points=object_points,
|
||||||
object_name=filename,
|
object_name=filename,
|
||||||
object_scale=objectScale,
|
object_scale=objectScale,
|
||||||
|
object_scale_is_max=objectScaleIsMax,
|
||||||
beam_count=beamCount,
|
beam_count=beamCount,
|
||||||
length_count=lengthCount,
|
length_count=lengthCount,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export const api = {
|
|||||||
seed = 42,
|
seed = 42,
|
||||||
outputDir = "sonar_dataset",
|
outputDir = "sonar_dataset",
|
||||||
objectScale = 1,
|
objectScale = 1,
|
||||||
|
objectScaleIsMax = false,
|
||||||
beamCount = 45,
|
beamCount = 45,
|
||||||
lengthCount = 45,
|
lengthCount = 45,
|
||||||
modelFile,
|
modelFile,
|
||||||
@@ -306,6 +307,7 @@ export const api = {
|
|||||||
formData.append("seed", String(seed));
|
formData.append("seed", String(seed));
|
||||||
formData.append("outputDir", outputDir || "sonar_dataset");
|
formData.append("outputDir", outputDir || "sonar_dataset");
|
||||||
formData.append("objectScale", String(objectScale ?? 1));
|
formData.append("objectScale", String(objectScale ?? 1));
|
||||||
|
formData.append("objectScaleIsMax", objectScaleIsMax ? "true" : "false");
|
||||||
formData.append("beamCount", String(beamCount ?? 45));
|
formData.append("beamCount", String(beamCount ?? 45));
|
||||||
formData.append("lengthCount", String(lengthCount ?? 45));
|
formData.append("lengthCount", String(lengthCount ?? 45));
|
||||||
formData.append("model", modelFile, modelFile.name || "model.obj");
|
formData.append("model", modelFile, modelFile.name || "model.obj");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
modelFile: null,
|
modelFile: null,
|
||||||
modelFileName: "",
|
modelFileName: "",
|
||||||
objectScale: 1,
|
objectScale: 1,
|
||||||
|
objectScaleIsMax: false,
|
||||||
beamCount: 45,
|
beamCount: 45,
|
||||||
lengthCount: 45,
|
lengthCount: 45,
|
||||||
lastResult: null,
|
lastResult: null,
|
||||||
@@ -117,7 +118,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.statusText = "Генерация датасета…";
|
this.statusText = "Генерация датасета…";
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}, dir=${this.outputDir}, model=${this.modelFileName}`,
|
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, dir=${this.outputDir}, model=${this.modelFileName}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await api.datasetGenerate({
|
const result = await api.datasetGenerate({
|
||||||
@@ -125,6 +126,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
seed: Number(this.seed) || 0,
|
seed: Number(this.seed) || 0,
|
||||||
outputDir: String(this.outputDir || "sonar_dataset"),
|
outputDir: String(this.outputDir || "sonar_dataset"),
|
||||||
objectScale: Number(this.objectScale) || 1,
|
objectScale: Number(this.objectScale) || 1,
|
||||||
|
objectScaleIsMax: !!this.objectScaleIsMax,
|
||||||
beamCount: Number(this.beamCount) || 45,
|
beamCount: Number(this.beamCount) || 45,
|
||||||
lengthCount: Number(this.lengthCount) || 45,
|
lengthCount: Number(this.lengthCount) || 45,
|
||||||
modelFile: this.modelFile,
|
modelFile: this.modelFile,
|
||||||
@@ -133,8 +135,11 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
this.resolvedOutputDir = result?.outputDir || null;
|
this.resolvedOutputDir = result?.outputDir || null;
|
||||||
const s = result?.stats || {};
|
const s = result?.stats || {};
|
||||||
this.statusText = `Готово: ${result.count} сцен → ${result.outputDir}`;
|
this.statusText = `Готово: ${result.count} сцен → ${result.outputDir}`;
|
||||||
|
const scaleNote = result.objectScaleIsMax
|
||||||
|
? `scale=1…${result.objectScale ?? this.objectScale} (макс.)`
|
||||||
|
: `scale=${result.objectScale ?? this.objectScale}`;
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), scale=${result.objectScale ?? this.objectScale}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
|
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
|
||||||
);
|
);
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||||
@@ -143,8 +148,10 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
||||||
);
|
);
|
||||||
for (const item of result.written || []) {
|
for (const item of result.written || []) {
|
||||||
|
const scalePart =
|
||||||
|
item.objectScale != null ? `, scale=${Number(item.objectScale).toFixed(3)}` : "";
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}`,
|
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}${scalePart}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,17 @@ function onHighlightClassChange(event) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Относительный масштаб объекта</span>
|
<span class="field-label-row">
|
||||||
|
<span>Относительный масштаб объекта</span>
|
||||||
|
<label class="check-inline" title="Верхняя граница: для каждой сцены масштаб случайный от 1 до заданного значения">
|
||||||
|
<input
|
||||||
|
v-model="store.objectScaleIsMax"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="store.busy"
|
||||||
|
/>
|
||||||
|
макс.
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
v-model.number="store.objectScale"
|
v-model.number="store.objectScale"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -128,7 +138,14 @@ function onHighlightClassChange(event) {
|
|||||||
step="0.05"
|
step="0.05"
|
||||||
:disabled="store.busy"
|
:disabled="store.busy"
|
||||||
/>
|
/>
|
||||||
<span class="ref-caption">1.0 = размер после нормализации mesh; >1 увеличивает объект</span>
|
<span class="ref-caption">
|
||||||
|
<template v-if="store.objectScaleIsMax">
|
||||||
|
Верхняя граница: для каждой сцены масштаб случайно из [1 … значение] (имитация разной высоты АНПА).
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
1.0 = размер после нормализации mesh; >1 увеличивает объект
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -142,7 +159,7 @@ function onHighlightClassChange(event) {
|
|||||||
:disabled="store.busy"
|
:disabled="store.busy"
|
||||||
/>
|
/>
|
||||||
<span class="ref-caption">
|
<span class="ref-caption">
|
||||||
Ширина рельефа (X): N лучей = N точек по ширине сетки дна.
|
Поперечные лучи эхолота (X): N лучей = N возвратов по ширине галса.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -157,7 +174,7 @@ function onHighlightClassChange(event) {
|
|||||||
:disabled="store.busy"
|
:disabled="store.busy"
|
||||||
/>
|
/>
|
||||||
<span class="ref-caption">
|
<span class="ref-caption">
|
||||||
Длина рельефа (Y): L = число точек по длине сетки дна.
|
Пинги вдоль курса (Y): L возвратов по длине. Итого точек сцены: N×L (первый отклик луча).
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -283,12 +300,23 @@ function onHighlightClassChange(event) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dataset-layout {
|
.dataset-layout {
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.dataset-sidebar {
|
.dataset-sidebar {
|
||||||
width: 320px;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: none;
|
||||||
overflow: auto;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
padding: 14px 16px 20px;
|
padding: 14px 16px 20px;
|
||||||
@@ -338,6 +366,27 @@ function onHighlightClassChange(event) {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.field-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.check-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.check-inline input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--chain-selected-border, #6ea8ff);
|
||||||
|
}
|
||||||
.field input,
|
.field input,
|
||||||
.field select {
|
.field select {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
@@ -424,17 +473,19 @@ function onHighlightClassChange(event) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.dataset-content {
|
.dataset-content {
|
||||||
min-height: 0;
|
|
||||||
height: calc(100vh - 76px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px 12px 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.viewer-wrap {
|
.viewer-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-height: 280px;
|
min-height: 0;
|
||||||
border: 1px solid var(--header-border);
|
border: 1px solid var(--header-border);
|
||||||
border-radius: var(--radius-sm, 6px);
|
border-radius: var(--radius-sm, 6px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -456,6 +507,7 @@ function onHighlightClassChange(event) {
|
|||||||
}
|
}
|
||||||
.log-panel {
|
.log-panel {
|
||||||
flex: 0 0 140px;
|
flex: 0 0 140px;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -466,10 +518,23 @@ function onHighlightClassChange(event) {
|
|||||||
.log {
|
.log {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: var(--muted-text);
|
color: var(--muted-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.dataset-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(200px, 36vh) minmax(0, 1fr);
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
.dataset-sidebar {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user