версия перед отпуском

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ваше Имя
2026-07-24 15:38:14 +03:00
co-authored by Cursor
parent a6e8ba31bd
commit 215b2108be
5 changed files with 477 additions and 56 deletions
+357 -52
View File
@@ -177,44 +177,83 @@ def _build_seafloor_meta(
*,
beam_count: int = 45,
length_count: int | None = None,
mle_mode: bool = False,
auv_depth: float | None = None,
swath_angle_deg: float = 90.0,
relief_scale_pct: float = 100.0,
) -> dict[str, Any]:
"""Build continuous seafloor heightfield parameters (no point cloud yet)."""
"""Build continuous seafloor heightfield parameters (no point cloud yet).
``relief_scale_pct`` scales random unevenness relative to scene size
(100 = baseline, clearly visible relief; higher = larger features).
"""
beams = max(1, int(beam_count))
length_points = beams if length_count is None else max(1, int(length_count))
size_x = rng.uniform(8.0, 16.0)
size_y = rng.uniform(8.0, 16.0)
base_z = rng.uniform(-1.2, -0.2)
amplitude = rng.uniform(0.05, 0.35)
frequency = rng.uniform(0.4, 2.2)
swath_deg = max(5.0, min(170.0, float(swath_angle_deg)))
relief_pct = max(5.0, min(500.0, float(relief_scale_pct)))
hills = [
(
rng.uniform(-size_x * 0.4, size_x * 0.4),
rng.uniform(-size_y * 0.4, size_y * 0.4),
rng.uniform(0.15, 0.7),
rng.uniform(0.6, 2.2),
if mle_mode and auv_depth is not None:
# Swath footprint from altitude above flat datum (like МЛЭ).
depth = max(0.3, float(auv_depth))
half_w = depth * math.tan(math.radians(swath_deg) * 0.5)
size_x = max(4.0, 2.0 * half_w * 1.08)
dx = size_x / max(beams - 1, 1)
size_y = max(4.0, dx * max(length_points - 1, 1) * rng.uniform(0.95, 1.12))
else:
size_x = rng.uniform(8.0, 16.0)
size_y = rng.uniform(8.0, 16.0)
base_z = rng.uniform(-1.2, -0.2)
# Feature size tied to scene so relief stays visible in the point cloud.
# At 100%: characteristic scale ≈ 28% of the shorter scene side.
scene_ref = max(4.0, min(size_x, size_y))
feature_scale = scene_ref * (relief_pct / 100.0) * 0.28
feature_scale = max(0.35, feature_scale)
max_rad = scene_ref * 0.42
# Background undulation — several waves across the patch.
amplitude = feature_scale * rng.uniform(0.12, 0.28)
frequency = (2.0 * math.pi / scene_ref) * rng.uniform(1.8, 3.6)
n_hills = rng.randint(2, 5)
hills = []
for _ in range(n_hills):
hills.append(
(
rng.uniform(-size_x * 0.38, size_x * 0.38),
rng.uniform(-size_y * 0.38, size_y * 0.38),
feature_scale * rng.uniform(0.45, 1.05),
min(max_rad, feature_scale * rng.uniform(0.75, 1.55)),
)
)
for _ in range(rng.randint(1, 4))
]
valleys = [
(
rng.uniform(-size_x * 0.4, size_x * 0.4),
rng.uniform(-size_y * 0.4, size_y * 0.4),
rng.uniform(0.1, 0.55),
rng.uniform(0.5, 2.0),
n_valleys = rng.randint(1, 4)
valleys = []
for _ in range(n_valleys):
valleys.append(
(
rng.uniform(-size_x * 0.38, size_x * 0.38),
rng.uniform(-size_y * 0.38, size_y * 0.38),
feature_scale * rng.uniform(0.3, 0.75),
min(max_rad, feature_scale * rng.uniform(0.65, 1.4)),
)
)
for _ in range(rng.randint(1, 3))
]
# Relief clutter / false features as heightfield bumps (not extra points)
bumps = [
(
rng.uniform(-size_x * 0.45, size_x * 0.45),
rng.uniform(-size_y * 0.45, size_y * 0.45),
rng.uniform(0.03, 0.35),
rng.uniform(0.15, 0.9),
# More medium bumps at lower scale; fewer but larger when scale is high.
density = max(0.45, min(1.8, 120.0 / max(relief_pct, 20.0)))
n_bumps = int(round(rng.uniform(10, 20) * density))
n_bumps = max(8, min(40, n_bumps))
bumps = []
for _ in range(n_bumps):
bumps.append(
(
rng.uniform(-size_x * 0.46, size_x * 0.46),
rng.uniform(-size_y * 0.46, size_y * 0.46),
feature_scale * rng.uniform(0.12, 0.45),
min(max_rad * 0.7, feature_scale * rng.uniform(0.25, 0.85)),
)
)
for _ in range(rng.randint(4, 14))
]
return {
"sizeX": size_x,
@@ -232,6 +271,11 @@ def _build_seafloor_meta(
"pingCount": length_points,
"swathBeams": beams,
"noise": rng.uniform(0.002, 0.02),
"mleMode": bool(mle_mode),
"auvDepth": float(auv_depth) if auv_depth is not None else None,
"swathAngleDeg": swath_deg,
"reliefScalePct": relief_pct,
"featureScale": feature_scale,
}
@@ -574,6 +618,174 @@ def _cast_echosounder_returns(
return rows, object_count, background_count
def _build_object_height_field(
world_pts: list[list[float]],
meta: dict[str, Any],
) -> dict[str, Any] | None:
"""Dense XY max-Z map of object surface for angled multibeam probes."""
if not world_pts:
return None
beams = int(meta["beamCount"])
length_points = int(meta["lengthCount"])
nx = max(32, min(256, beams * 3))
ny = max(32, min(256, length_points * 3))
size_x = float(meta["sizeX"])
size_y = float(meta["sizeY"])
half_x = size_x * 0.5
half_y = size_y * 0.5
cell_w = size_x / max(nx - 1, 1)
cell_h = size_y / max(ny - 1, 1)
splat_rx = cell_w * 1.1
splat_ry = cell_h * 1.1
field: list[list[float | None]] = [[None for _ in range(nx)] for _ in range(ny)]
for px, py, pz in world_pts:
fx = 0.0 if nx == 1 else (px + half_x) / size_x * (nx - 1)
fy = 0.0 if ny == 1 else (py + half_y) / size_y * (ny - 1)
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 >= nx or yi >= ny:
continue
cx = -half_x if nx == 1 else (-half_x + size_x * xi / (nx - 1))
cy = -half_y if ny == 1 else (-half_y + size_y * yi / (ny - 1))
if abs(px - cx) > splat_rx or abs(py - cy) > splat_ry:
continue
floor_z = _height_at(cx, cy, meta)
if pz < floor_z - 0.01:
continue
prev = field[yi][xi]
if prev is None or pz > prev:
field[yi][xi] = pz
return {
"nx": nx,
"ny": ny,
"sizeX": size_x,
"sizeY": size_y,
"field": field,
}
def _sample_object_height_field(
field_info: dict[str, Any] | None,
x: float,
y: float,
) -> float | None:
if not field_info:
return None
nx = int(field_info["nx"])
ny = int(field_info["ny"])
size_x = float(field_info["sizeX"])
size_y = float(field_info["sizeY"])
half_x = size_x * 0.5
half_y = size_y * 0.5
u = (x + half_x) / max(size_x, 1e-6)
v = (y + half_y) / max(size_y, 1e-6)
if u < -0.02 or u > 1.02 or v < -0.02 or v > 1.02:
return None
u = min(1.0, max(0.0, u))
v = min(1.0, max(0.0, v))
fx = u * (nx - 1)
fy = v * (ny - 1)
i0 = int(math.floor(fx))
j0 = int(math.floor(fy))
i1 = min(nx - 1, i0 + 1)
j1 = min(ny - 1, j0 + 1)
vals = [
field_info["field"][j0][i0],
field_info["field"][j0][i1],
field_info["field"][j1][i0],
field_info["field"][j1][i1],
]
present = [z for z in vals if z is not None]
if not present:
return None
return max(present)
def _cast_mle_fan_returns(
rng: random.Random,
meta: dict[str, Any],
object_field: dict[str, Any] | None,
*,
auv_depth: float,
swath_angle_deg: float = 90.0,
) -> tuple[list[list[float]], int, int]:
"""Multibeam fan: beamCount rays × lengthCount pings from constant altitude.
AUV height is measured from flat seafloor datum (baseZ), not terrain-following.
"""
beams = int(meta["beamCount"])
length_points = int(meta["lengthCount"])
noise = float(meta.get("noise", 0.01))
base_z = float(meta["baseZ"])
depth = max(0.3, float(auv_depth))
origin_z = base_z + depth
swath = math.radians(max(5.0, min(170.0, float(swath_angle_deg))))
size_y = float(meta["sizeY"])
half_y = size_y * 0.5
# Reach outer beams with margin past the swath edge.
max_range = max(depth / max(math.cos(swath * 0.5), 0.12) * 1.35, depth * 2.0, 4.0)
step = max(0.08, min(0.45, max_range / 220.0))
max_steps = int(math.ceil(max_range / step)) + 2
rows: list[list[float]] = []
object_count = 0
background_count = 0
for yi in range(length_points):
y = -half_y if length_points == 1 else (-half_y + size_y * yi / (length_points - 1))
ox, oy, oz = 0.0, y, origin_z
for xi in range(beams):
t = 0.5 if beams == 1 else xi / (beams - 1)
angle = -swath * 0.5 + swath * t
dx = math.sin(angle)
dy = 0.0
dz = -math.cos(angle)
hit_x, hit_y, hit_z = ox, oy, base_z
is_object = False
px = ox + dx * step * 0.5
py = oy + dy * step * 0.5
pz = oz + dz * step * 0.5
for _ in range(max_steps):
dist = math.hypot(px - ox, py - oy, pz - oz)
if dist > max_range:
break
floor_z = _height_at(px, py, meta)
obj_z = _sample_object_height_field(object_field, px, py)
if obj_z is not None and pz <= obj_z and obj_z >= floor_z - 0.01:
hit_x, hit_y, hit_z = px, py, obj_z
is_object = True
break
if pz <= floor_z:
hit_x, hit_y, hit_z = px, py, floor_z
break
px += dx * step
py += dy * step
pz += dz * step
else:
# No intersection within range — project to flat nadir estimate.
hit_x = ox + dx * max_range
hit_y = oy + dy * max_range
hit_z = _height_at(hit_x, hit_y, meta)
z = hit_z + rng.uniform(-noise, noise)
if is_object:
cls = 1.0
object_count += 1
else:
cls = 0.0
background_count += 1
rows.append([float(hit_x), float(hit_y), float(z), 0.0, 0.0, 0.0, cls])
return rows, object_count, background_count
# ---------------------------------------------------------------------------
# Balance plan + single scene
# ---------------------------------------------------------------------------
@@ -657,12 +869,19 @@ def generate_sonar_scene(
object_kind: str | None = None,
beam_count: int = 45,
length_count: int | None = None,
mle_mode: bool = False,
auv_depth: float | None = None,
swath_angle_deg: float = 90.0,
relief_scale_pct: float = 100.0,
) -> dict[str, Any]:
"""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.
object_kind ``pipe`` places a border-to-border pipeline with optional silt.
When ``mle_mode`` is True, returns are cast as a multibeam fan from constant
altitude ``auv_depth`` above the flat seafloor datum (baseZ).
"""
rng = random.Random(int(seed))
if visibility not in ("absent",) + VISIBILITY_TIERS:
@@ -671,11 +890,26 @@ def generate_sonar_scene(
if visibility != "absent" and kind != "pipe" and not object_points:
raise ValueError("object_points required when visibility is not absent.")
meta = _build_seafloor_meta(rng, beam_count=beam_count, length_count=length_count)
use_mle = bool(mle_mode)
depth = max(0.3, float(auv_depth)) if auv_depth is not None else None
if use_mle and depth is None:
depth = 2.5
meta = _build_seafloor_meta(
rng,
beam_count=beam_count,
length_count=length_count,
mle_mode=use_mle,
auv_depth=depth,
swath_angle_deg=swath_angle_deg,
relief_scale_pct=relief_scale_pct,
)
expected = int(meta["beamCount"]) * int(meta["lengthCount"])
object_info: dict[str, Any] | None = None
object_hits: dict[tuple[int, int], float] | None = None
object_field: dict[str, Any] | None = None
world: list[list[float]] | None = None
if visibility != "absent":
if kind == "pipe":
world, object_info = _place_pipeline_in_scene(
@@ -692,14 +926,32 @@ def generate_sonar_scene(
object_points,
object_scale=object_scale,
)
object_hits = _rasterize_object_hits(world, meta)
object_info["hitCellCount"] = len(object_hits)
object_info["keptCount"] = len(object_hits)
if use_mle:
object_field = _build_object_height_field(world, meta)
hit_cells = 0
if object_field:
for row in object_field["field"]:
hit_cells += sum(1 for z in row if z is not None)
object_info["hitCellCount"] = hit_cells
object_info["keptCount"] = hit_cells
else:
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 use_mle:
rows, object_point_count, background_point_count = _cast_mle_fan_returns(
rng,
meta,
object_field,
auv_depth=float(depth),
swath_angle_deg=float(meta.get("swathAngleDeg", swath_angle_deg)),
)
else:
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)}")
@@ -713,6 +965,8 @@ def generate_sonar_scene(
"pointCount": len(rows),
"objectPointCount": object_point_count,
"backgroundPointCount": background_point_count,
"auvDepth": float(depth) if use_mle else None,
"mleMode": use_mle,
"rows": rows,
"points": xyz,
"meta": {
@@ -731,6 +985,10 @@ def generate_sonar_scene(
"pingCount": meta.get("pingCount"),
"swathBeams": meta.get("swathBeams"),
"gridPointCount": expected,
"mleMode": use_mle,
"auvDepth": float(depth) if use_mle else None,
"swathAngleDeg": meta.get("swathAngleDeg"),
"reliefScalePct": meta.get("reliefScalePct"),
"floorFeatures": {
"hills": len(meta["hills"]),
"valleys": len(meta["valleys"]),
@@ -812,8 +1070,31 @@ def build_run_manifest(
nearly_hidden_pct: float = 20.0,
partial_pct: float = 40.0,
visible_pct: float = 40.0,
mle_mode: bool = False,
auv_depth_min: float | None = None,
auv_depth_max: float | None = None,
relief_scale_pct: float = 100.0,
) -> dict[str, Any]:
model_filename = _normalize_model_filename(object_name)
settings: dict[str, Any] = {
"count": int(count),
"seed": int(seed),
"outputDir": str(output_dir),
"objectScale": float(object_scale),
"objectScaleIsMax": bool(object_scale_is_max),
"beamCount": int(beam_count),
"lengthCount": int(length_count),
"objectName": model_filename,
"absentPct": float(absent_pct),
"nearlyHiddenPct": float(nearly_hidden_pct),
"partialPct": float(partial_pct),
"visiblePct": float(visible_pct),
"mleMode": bool(mle_mode),
"reliefScalePct": float(relief_scale_pct),
}
if mle_mode:
settings["auvDepthMin"] = float(auv_depth_min) if auv_depth_min is not None else None
settings["auvDepthMax"] = float(auv_depth_max) if auv_depth_max is not None else None
return {
"version": DATASET_RUN_VERSION,
"generatedAt": datetime.now(timezone.utc).isoformat(),
@@ -821,20 +1102,7 @@ def build_run_manifest(
"outputDir": str(run_dir),
"baseDir": str(base_dir),
"objectName": model_filename,
"settings": {
"count": int(count),
"seed": int(seed),
"outputDir": str(output_dir),
"objectScale": float(object_scale),
"objectScaleIsMax": bool(object_scale_is_max),
"beamCount": int(beam_count),
"lengthCount": int(length_count),
"objectName": model_filename,
"absentPct": float(absent_pct),
"nearlyHiddenPct": float(nearly_hidden_pct),
"partialPct": float(partial_pct),
"visiblePct": float(visible_pct),
},
"settings": settings,
"objectVertexCount": int(object_vertex_count),
"stats": stats,
"written": written,
@@ -1144,6 +1412,10 @@ def iter_generate_dataset(
nearly_hidden_pct: float = 20.0,
partial_pct: float = 40.0,
visible_pct: float = 40.0,
mle_mode: bool = False,
auv_depth_min: float = 2.0,
auv_depth_max: float = 8.0,
relief_scale_pct: float = 100.0,
):
"""Yield NDJSON-friendly progress events, then a final ``done`` payload.
@@ -1183,6 +1455,18 @@ def iter_generate_dataset(
if length_count > 1024:
raise ValueError("length_count (Длина) must be <= 1024")
use_mle = bool(mle_mode)
depth_lo = max(0.3, float(auv_depth_min))
depth_hi = max(0.3, float(auv_depth_max))
if depth_hi < depth_lo:
depth_lo, depth_hi = depth_hi, depth_lo
if use_mle and (not math.isfinite(depth_lo) or not math.isfinite(depth_hi)):
raise ValueError("Диапазон высот must be finite numbers")
relief_pct = max(5.0, min(500.0, float(relief_scale_pct)))
if not math.isfinite(relief_pct):
raise ValueError("relief_scale_pct (Размер неровностей) must be finite")
absent_pct = _clamp_pct(absent_pct, name="absent_pct")
nearly_hidden_pct = _clamp_pct(nearly_hidden_pct, name="nearly_hidden_pct")
partial_pct = _clamp_pct(partial_pct, name="partial_pct")
@@ -1236,6 +1520,7 @@ def iter_generate_dataset(
scene_scale = scene_rng.uniform(lo, hi)
else:
scene_scale = object_scale
scene_depth = scene_rng.uniform(depth_lo, depth_hi) if use_mle else None
scene = generate_sonar_scene(
seed=scene_seed,
visibility=visibility,
@@ -1244,6 +1529,9 @@ def iter_generate_dataset(
object_kind=kind,
beam_count=beam_count,
length_count=length_count,
mle_mode=use_mle,
auv_depth=scene_depth,
relief_scale_pct=relief_pct,
)
if scene.get("object") and scene["object"].get("vertexCount"):
object_vertex_count = int(scene["object"]["vertexCount"])
@@ -1260,6 +1548,7 @@ def iter_generate_dataset(
"pointCount": scene["pointCount"],
"objectPointCount": scene["objectPointCount"],
"objectScale": scene_scale,
"auvDepth": scene.get("auvDepth"),
"files": paths,
}
written.append(entry)
@@ -1302,6 +1591,10 @@ def iter_generate_dataset(
"seed": int(seed),
"beamCount": beam_count,
"lengthCount": length_count,
"mleMode": use_mle,
"auvDepthMin": depth_lo if use_mle else None,
"auvDepthMax": depth_hi if use_mle else None,
"reliefScalePct": relief_pct,
"objectName": object_name,
"objectKind": kind,
"objectScale": object_scale,
@@ -1334,6 +1627,10 @@ def iter_generate_dataset(
nearly_hidden_pct=nearly_hidden_pct,
partial_pct=partial_pct,
visible_pct=visible_pct,
mle_mode=use_mle,
auv_depth_min=depth_lo if use_mle else None,
auv_depth_max=depth_hi if use_mle else None,
relief_scale_pct=relief_pct,
)
write_run_manifest(run_dir, manifest)
result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME)
@@ -1356,6 +1653,10 @@ def generate_dataset(
nearly_hidden_pct: float = 20.0,
partial_pct: float = 40.0,
visible_pct: float = 40.0,
mle_mode: bool = False,
auv_depth_min: float = 2.0,
auv_depth_max: float = 8.0,
relief_scale_pct: float = 100.0,
) -> dict[str, Any]:
"""Generate `count` unique scenes into a new timestamped run folder under output_dir."""
result: dict[str, Any] | None = None
@@ -1374,6 +1675,10 @@ def generate_dataset(
nearly_hidden_pct=nearly_hidden_pct,
partial_pct=partial_pct,
visible_pct=visible_pct,
mle_mode=mle_mode,
auv_depth_min=auv_depth_min,
auv_depth_max=auv_depth_max,
relief_scale_pct=relief_scale_pct,
):
if event.get("type") == "done":
result = event["result"]
+8
View File
@@ -503,6 +503,10 @@ async def dataset_generate(
nearlyHiddenPct: float = Form(20.0),
partialPct: float = Form(40.0),
visiblePct: float = Form(40.0),
mleMode: bool = Form(False),
auvDepthMin: float = Form(2.0),
auvDepthMax: float = Form(8.0),
reliefScalePct: float = Form(100.0),
modelPreset: str | None = Form(None),
model: UploadFile | None = File(None),
) -> StreamingResponse:
@@ -549,6 +553,10 @@ async def dataset_generate(
nearly_hidden_pct=nearlyHiddenPct,
partial_pct=partialPct,
visible_pct=visiblePct,
mle_mode=mleMode,
auv_depth_min=auvDepthMin,
auv_depth_max=auvDepthMax,
relief_scale_pct=reliefScalePct,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except ValueError as exc: