Sunday, October 18, 2026

 """Self-check for tests/photogrammetry_fixtures.py 

Run: pytest tests/test_photogrammetry_fixtures.py --no-cov

"""


import json

import urllib.request


import cv2

import numpy as np


from core.azure.survey_corners import track_from_srt

from tests.photogrammetry_fixtures import ( # noqa: F401 (fixtures are imported for pytest)

    encode_multipart,

    fake_nodeodm,

    frame_overlap_fraction,

    ground,

    ground_truth_orthomosaic,

    no_gps_tour,

    two_tours,

    union_bbox_px,

)



def _post(url, fields, files=()):

    body, content_type = encode_multipart(fields, list(files))

    request = urllib.request.Request(url, data=body, headers={"Content-Type": content_type}, method="POST")

    return json.load(urllib.request.urlopen(request))



def _get_json(url):

    return json.load(urllib.request.urlopen(url))



def test_tours_have_the_configured_overlap_and_share_area(ground, two_tours):

    a, b = two_tours

    assert len(a.frames) > 10 and len(b.frames) > 10

    # consecutive frames along a flight line overlap ~75 % (the default forward overlap)

    assert abs(frame_overlap_fraction(a.frames[0], a.frames[1]) - 0.75) < 0.05

    # the two tours overlap each other (shared flight lines), so one mosaic can be built from both

    assert any(frame_overlap_fraction(fa, fb) > 0.3 for fa in a.frames for fb in b.frames)

    x0, y0, w, h = union_bbox_px([a, b])

    assert ground_truth_orthomosaic(ground, [a, b]).shape[:2] == (h, w)



def test_tour_writes_frames_srt_and_manifest_that_survey_corners_can_parse(two_tours, no_gps_tour, tmp_path):

    a, _ = two_tours

    paths = a.write(tmp_path / "a")

    assert len(paths) == len(a.frames) and (tmp_path / "a" / "frame1.jpg").exists()

    manifest = json.loads((tmp_path / "a" / "manifest.json").read_text())

    assert [m["frame_id"] for m in manifest] == [f.frame_id for f in a.frames]


    track = track_from_srt(str(tmp_path / "a" / "tourA.SRT")) # the real parser used by /corners

    assert track is not None and len(track) == len(a.frames)

    assert np.ptp(track[:, 0]) > 50 # ~112 m east span


    no_gps_tour.write(tmp_path / "n")

    assert not list((tmp_path / "n").glob("*.SRT")) # no telemetry for the no-GPS tour

    assert all(m["lat"] is None for m in json.loads((tmp_path / "n" / "manifest.json").read_text()))



def test_frame_lat_lon_matches_its_pixel_position(ground, two_tours):

    f = two_tours[0].frames[0]

    gt = ground.geotransform()

    cx, cy = f.bbox_px[0] + f.bbox_px[2] / 2, f.bbox_px[1] + f.bbox_px[3] / 2

    assert abs(f.lat - (gt["origin_lat"] + cy * gt["lat_per_px"])) < 1e-8

    assert abs(f.lon - (gt["origin_lon"] + cx * gt["lon_per_px"])) < 1e-8



def test_fake_nodeodm_task_lifecycle(ground, two_tours, fake_nodeodm):

    a, b = two_tours

    x0, y0, w, h = union_bbox_px([a, b])

    options = json.dumps([{"name": "fast-orthophoto", "value": True}])

    task = _post(fake_nodeodm.url + "/task/new", {"name": "t", "options": options},

                 [("images", fr.filename, b"x") for fr in a.frames[:3]])["uuid"]

    assert fake_nodeodm.tasks[task].images == [fr.filename for fr in a.frames[:3]]


    codes = [_get_json(f"{fake_nodeodm.url}/task/{task}/info")["status"]["code"] for _ in range(4)]

    assert codes == [10, 20, 20, 40] # queued, running, running, completed


    png = urllib.request.urlopen(f"{fake_nodeodm.url}/task/{task}/download/orthophoto.tif").read()

    mosaic = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)

    assert mosaic.shape[:2] == (h, w) # the ground-truth orthomosaic

    shots = _get_json(f"{fake_nodeodm.url}/task/{task}/download/shots.geojson")

    assert len(shots["features"]) == len(a.frames) + len(b.frames)


    _post(fake_nodeodm.url + "/task/cancel", {"uuid": task})

    assert _get_json(f"{fake_nodeodm.url}/task/{task}/info")["status"]["code"] == 50 # cancelled



Saturday, October 17, 2026

 Migration strategy for permanent role-based assignments in Azure for resources in a subscription:

This article describes a logical end state that becomes possible after an initial review, cleanup and optimization and justifies it to stakeholders who often want to contend that delivery requirements can be met by cleanup and optimization alone. The state is realized through an entitlement model that separates business access decisions from Azure implementation details, which allows the access model to survive platform, application, and organizational change over many years.

First, we must acknowledge that there are near-term economics in doing just cleanup without imposing any model or organization. Converting existing permanent assignments into eligible PIM assignments, consolidating resource-level role assignments to resource-group scope, and reducing the number of direct RBAC assignments is probably the fastest path to risk reduction. It produces measurable audit benefits quickly, requires relatively little redesign, minimizes disruption to application teams, and largely preserves today's operational model. This is an infrastructure optimization exercise. It makes today's model cleaner without fundamentally changing how access is governed.

Second, we are solving a problem we typically uncover in the analysis that this is a dual problem of RBAC-assignment and identity-governance. Same combinations of Azure roles repeat across users over and over again but often manifest through many different groups and custom-role variations. While we can reduce those patterns into a relatively small number of reusable job-function personas and highlight where custom roles represent specialized business functions rather than merely technical permissions. The fact that the same role combinations repeatedly emerge is evidence that people naturally organize around business responsibilities such as application development, application operations, platform engineering, storage administration, integration engineering, and data engineering, not around individual Azure resources. The entitlement catalog therefore models the way work is performed rather than the way Azure resources happen to be arranged today. The entitlement model separates "what job a person performs" from "where resources happen to live." In effect, the model transforms access management from a collection of infrastructure-specific permissions into a business-driven authorization framework that can evolve independently of resource groups, subscriptions, applications, and cloud platforms

Third, resource groups are not stable business constructs. They are deployment constructs. Applications move between subscriptions, environments are restructured, landing zones evolve, new resource types are introduced, acquisitions occur, and cloud architectures change. A resource-group-centric model assumes that the resource-group boundary is the most important long-term boundary. History shows that those boundaries change frequently. Job functions, however, change much more slowly. There will almost certainly still be application developers, platform engineers, storage administrators, and integration engineers five years from now, even if the names of the subscriptions, resource groups, AKS clusters, Key Vaults, Databricks workspaces, Event Hubs namespaces, or storage accounts have changed entirely. Because personas are aligned with enduring business responsibilities, they are inherently more resilient than resource-centric RBAC designs.

Fourth, custom roles are where the resource-group approach becomes increasingly difficult to maintain. While we can create custom roles that span multiple resource types within a resource group, that. works successfully only when there are a small number of applications and a small number of resource patterns. Eventually, however, they proliferate and differ only slightly from one another. At that point nobody can confidently answer simple questions such as "What does a Data Engineer receive?" or "Why does this application team need this custom role?" The entitlement model addresses this by making roles implementation details and personas the primary unit of governance. A manager approves "Data Engineer" or "Platform Engineer." The underlying RBAC bundle can evolve without repeatedly re-educating reviewers and makes subsequent access reviews more transparent and easier to complete for managers.

Fifth, the entitlement model scales much better for certification, audit, and compliance activities. Resource-group-based reviews require reviewers to evaluate technical assignments and with full purview of current landscape and historical context: Contributor on resource group A, Key Vault role on resource group B, Event Hubs role on resource group C, and so forth. A persona model allows reviewers to answer a far simpler question: "Does this person still perform the duties of a Platform Engineer?" Most auditors, application owners, and managers can answer that question confidently; far fewer can evaluate dozens of Azure RBAC assignments in context. The result is better access reviews and lower risk of rubber-stamp approvals.

Sixth, the entitlement model future-proofs hybrid and multi-platform environments. Today's discussion centers on Azure RBAC, but tomorrow there may be a need to govern Azure, Databricks, Snowflake, GitHub, ServiceNow, Kubernetes, SaaS platforms, and on-premises systems together. Resource-group-driven governance does not extend naturally beyond Azure. Personas do. A "Data Engineer" entitlement can someday include Azure roles, Databricks workspace permissions, GitHub repository access, and Snowflake roles. That makes the model extensible in directions that are probably impossible to predict today.

Seventh, initial reviews often find that users cannot cleanly fit into a reusable entitlement and therefore require exceptions or that users wear multiple hats and can change them. In other words, the model does not merely assign people to personas; it reveals complexity that currently exists but is hidden behind groups and custom roles. That transparency is valuable because exceptions become specific, measurable, and reviewable instead of remaining embedded inside opaque group memberships and custom role definitions.

Eighth, this model is not an all-or-nothing proposal. Immediate and interim exercise and the entitlement strategy are complementary. First consolidate RBAC to resource-group scope, remove unnecessary direct assignments, and convert elevated permissions to PIM eligibility. Once that cleanup is complete, the entitlement catalog becomes the governance layer above it. In that model, resource groups remain the technical enforcement boundary while personas become the business-access boundary. Engineers implement RBAC. Managers approve personas. Identity governance teams maintain mappings between the two. Each layer serves its appropriate purpose.

A cleanup approach optimizes the current state, while the entitlement approach creates a durable operating model. One reduces administrative effort today. The other reduces administrative effort every year thereafter. If the organization expects the application portfolio, cloud footprint, custom roles, and hybrid platforms to continue growing, then investing in personas is not a fad. It is the architectural layer that prevents access management from becoming increasingly tied to the accidental structure of today's Azure environment. The reason to adopt personas is that they are the only abstraction in the model that represents stable business responsibilities rather than transient cloud infrastructure.


Friday, October 16, 2026

 Insider-Risk Management with DVSA (https://github.com/ravibeta/dvsa-api) platform: 

Insider risk concerns model behavior and outputs that could significantly affect the analytics stack DVSA provides for drone AI applications, as described in the eponymous article. This document applies that article’s recommended trust architecture to our system. 

Models act like powerful insiders: they have access, intent, and the ability to act. DVSA relies on them for tasks ranging from geospatial pipelines to feedback control loops in critical mission systems. Our earlier work proposed two safeguards. The first is a health signal that measures semantic drift as the cosine distance between rolling embedding centroids. The second is a tiered architecture in which Qdrant global search and LLM agent reasoning run in the cloud. We now extend our scope beyond the data and infrastructure to the models themselves: how they act, which tools they can call, and how they might be manipulated or abused. 

In DVSA, every model, agent, and tool is an insider whose capabilities could leak information, misroute queries, amplify bias, or be steered by hostile input. We test the agent layer against prompt-injection attacks that hide directives in video metadata, and we mitigate them with SQL schema allowlists and input sanitization. We also protect data with AES-256-GCM encryption and differential privacy. Three principles govern every potential insider action: least privilege, strong provenance, and continuous monitoring. 

Least privilege begins with tools. The agentic retrieval layer exposes eight specialized functions, from semantic frame search to heatmap generation, and each should be tightly scoped. An agent answering a parking-lot query, for instance, should search only the relevant account, mission, and time window rather than the entire corpus. In Azure AI Search, we add accountid and video-id as filterable fields and pass OData filters such as odata_filter = "accountid eq '008333'". As a result, queries reach only the vectors derived from the user’s own uploaded videos, even though the index is shared across users. These filters are mandatory. Every agent tool call must carry tenant and mission context, which the API enforces and a policy engine such as Open Policy Agent validates. A model should never see more data than the current question requires. 

Provenance and lineage form the second pillar. We already attach provenance atomically at ingest and maintain full lineage graphs that trace every transformation from raw frame to archived artifact. These graphs support compliance audits, targeted reprocessing after model upgrades, and complete erasure cascades. We can extend this lineage to model decisions. Azure already produces a trace for every agent run, recording the tools called, frames retrieved, filters applied, model versions used, and the content of the final answer. These traces become our insider-activity logs. We can mine them for anomalies, such as an agent suddenly querying beyond its usual geospatial bounds or calling tools in a sequence that resembles reconnaissance rather than analysis. We then feed them into the same observability stack that monitors ingestion, localization, and drift. The result is a new signal, “model behavior drift,” which measures how far today’s tool-calling patterns deviate from a baseline of legitimate use. 

Exfiltration and misuse map cleanly onto DVSA’s multi-tenant, high-value corpus, because aerial urban video is sensitive by default. Our GDPR section already addresses this through four measures: purpose limitation enforced at flight-plan registration, importance sampling for data minimization, cascading deletion across the provenance database, S3, and Qdrant vectors, and latitude-longitude-time frame search for data subject access requests. We can add exfiltration guards at the agent layer. These would detect queries that resemble bulk export, such as “give me all frames with cars in Redmond between 2023 and 2025,” cap result sizes and aggregation granularity, and require elevated approval or out-of-band justification for queries that exceed those caps. We must scan not only prompts and their metadata but also the model behavior that attempts to fulfill them. 

Model supply chain and configuration drift are another dimension. DVSA requires embeddings to carry model-version metadata and orthophotos to be refreshed quarterly, and we trigger re-embedding when measured drift crosses a threshold. Building on this, we can treat model binaries, configuration files, and tool registries as high-risk artifacts. Any change to CLIP, YOLO, Qdrant index parameters, or agent tool definitions should pass through signed, auditable change control. Our dvsa_ Prometheus namespace can expose configuration hashes and model-version distributions and raise an alert when an unexpected version reaches production. This is how we catch a compromised model or misconfigured tool before it behaves like a malicious insider. 

Segmentation and isolation ensure that no single insider sees everything. DVSA’s tiered edge-versus-cloud architecture already provides a degree of segmentation, with Qdrant global search and LLM agent reasoning confined to the cloud. We can strengthen it in two ways: edge agents see only their own mission’s embeddings and metadata, and cloud agents operate in per-tenant namespaces with strict cross-tenant barriers. For cross-mission analytics, we can introduce specialized “auditor” agents with narrower tools and stronger logging instead of letting general-purpose agents roam the corpus. In effect, we assign our models roles, such as mission analyst, fleet auditor, and compliance checker, and give each role its own toolset and data scope. 

Finally, observability should cover activities as well as metrics. Alongside existing metrics such as agent task-completion rate and cost per query, we can add agent intent classification and tool-use entropy. A lightweight classifier could label each agent run as normal analysis, exploratory browsing, or suspicious bulk access based on its tool sequence, filter breadth, and result size. Tracking tool-use entropy per agent would reveal sudden spikes in unusual tools or filters that may indicate insider-like exploration. 

Taken together, these measures meet the expectations for insider-risk management: treating models, agents, and tools as insiders; atomic provenance; semantic drift monitoring; tiered edge and cloud deployment; access control lists; encryption; differential privacy; and a rich observability stack. 

 

Reference:  Insider risk eponymous article: https://snscratchpad.com/posts/models-as-insider-risks/  

Wednesday, October 14, 2026

 Self-Designed flight paths for fleet of autonomous drones

Leap71’s Noyron engine program is an example of encoding engineering as executable logic. Noyron is described by Leap71 as a “Large Computational Engineering Model” that takes a specification—thrust level, propellants, operating regime, manufacturing constraints—and directly generates manufacturable hardware, including complex internal fuel channels and radically different architectures such as bell nozzles and aerospikes. It does this by embedding first principles physics, engineering rules, and additive manufacturing constraints into a coherent codebase that outputs geometry and predicted operating parameters, which are then validated and refined through physical testing. 

The key idea is that Noyron is not a generic AI model trained on images or text; it is a domain specific computational model whose “DNA” encodes physics informed logic and design rules. Leap71 explicitly describes different engines—pressure fed demonstrators, monolithic copper aerospikes, and meganewton class turbomachinery systems—as “phenotypes of the same computational DNA.” That means the same underlying model can generate multiple architectures by varying parameters and constraints, without re deriving the engineering from scratch. The model produces geometry along with predicted pressures, temperatures, and flow rates, which are compared against test data from hot fire campaigns. Those tests feed back into the model, tightening its representation of propellant behavior, transient startup conditions, and material limits. 

Noyron’s efficiency climb up path looks like this:

1. Encode the domain: formalize physics, constraints, and design rules as executable code rather than informal documents.

2. Generate candidate designs directly from specifications.

3. Predict performance using embedded physics and logic.

4. Manufacture and test selected designs.

5. Compare predictions to measurements and update the model’s internal representation.

6. Iterate, exploring new phenotypes while reusing the same computational DNA.

The curvaceous fuel channels and unusual internal geometries are not aesthetic flourishes; they are emergent results of a model that is free to search the design space under constraints of fluid dynamics, heat transfer, structural integrity, and additive manufacturability. Large format metal powder bed fusion systems make it possible to print these complex monolithic components, which in turn allows Noyron to consider geometries that would be impractical with traditional multi part assemblies. 

Translating that methodology to drone fleet trajectory planning in a 3D space, a computational model must encode the rules of motion, constraints, and objectives, and then generate trajectories that maximize throughput between sources and destinations. Instead of fuel channels inside a rocket engine, your “geometry” is the set of paths through a discretized 3D volume—your matrix of waypoints—subject to dynamics, collision avoidance, and resource limits.

The first step is to define the domain in the code. That means:

• Drone dynamics: equations of motion, maximum acceleration, velocity limits, turning radii, climb and descent rates, and energy consumption as a function of trajectory.

• Environmental constraints: no fly zones, obstacles, altitude bands, wind fields if relevant, and communication or sensing limitations.

• Fleet level constraints: minimum separation distances, scheduling windows, launch and landing capacities, and any shared resources such as charging pads or air corridors.

• Objective function: what “throughput” means in your context—number of deliveries per unit time, total payload moved, latency bounds, or a weighted combination.

In drone flight, physics informed, constraint aware optimization model over a graph or continuous state space. You can represent the 3D space as a grid of nodes (waypoints) with edges representing feasible transitions that respect kinematic limits. Each edge carries a cost: time, energy, risk, or congestion. The fleet problem then becomes a multi agent routing and scheduling problem on this graph.

To mimic Noyron’s generative design approach, you don’t hand draw paths; you let the model synthesize them. Classical tools here include:

• Graph search algorithms such as A* or D* for single agent shortest paths, extended to multi agent settings with conflict based search or reservation based planning.

• Mixed integer linear or nonlinear programming formulations for multi commodity flow, where each drone is a “commodity” moving through the network, and throughput is maximized subject to capacity and separation constraints.

• Model predictive control (MPC) for continuous trajectory refinement, where a controller repeatedly solves a constrained optimization problem over a short horizon, updating paths as conditions change.

Noyron’s test and refine cycle can have an equivalent with simulated annealing. You start with a baseline model of dynamics and constraints, generate trajectories, simulate them, and then compare predicted performance to actual data from flight logs. Deviations—unexpected delays, energy usage, congestion patterns—are used to update the model. Over time fleet motion simulations become more accurate and can explore more aggressive optimizations.

One way to structure this is:

1. Build a physics informed simulator for your drones and environment. This is your analogue to Noyron’s internal physics engine.

2. Define a parametric policy or planner that maps tasks and environmental states to trajectories. Parameters might include weights in the objective function, safety margins, or routing heuristics.

3. Use optimization or learning to tune those parameters against throughput metrics in simulation, subject to safety constraints.

4. Deploy selected policies in limited real world trials, collect telemetry, and compare against simulation predictions.

5. Update both the simulator (to better match reality) and the planner (to exploit improved understanding), then iterate.

You can incorporate machine learning, but it should be grounded in the physics and constraints you’ve encoded. For example, reinforcement learning can be used to discover routing policies that maximize throughput, but the action space and reward function must respect collision avoidance and dynamic limits. Physics informed neural networks or hybrid models can refine parts of your simulator, such as energy consumption under complex wind conditions, while the overall planner remains optimization based. Data tightens the parameters rather than replacing the rules.

Drones move in continuous space, and you can generate continuous trajectories using optimal control and then project them onto a waypoint graph for scheduling and deconfliction.

Throughput maximization in a fleet context also demands attention to temporal coupling. It is not enough to find optimal paths; you must coordinate departures, arrivals, and crossings so that the system flows smoothly. Techniques from air traffic management and multi robot systems are relevant here: time expanded graphs where nodes represent space time positions, reservation tables that block edges during certain intervals, and priority schemes that resolve conflicts. Your computational model should be able to generate entire schedules—who goes where and when—given a set of tasks and constraints and then adjust them online as conditions change.

The final piece, inspired by Leap71’s code first philosophy, is to keep the entire pipeline executable and inspectable. Noyron’s strength lies in the fact that its engineering knowledge is trans codified into code that directly produces geometry and operating predictions. For your drone fleet, aim for a similar property given a specification of tasks, constraints, and objectives, the system should automatically produce trajectories and schedules, along with predicted metrics such as total throughput, average latency, and safety margins. When reality diverges, you don’t tweak paths manually; you update the model and let it regenerate solutions.

In practice, that means building:

• A well documented core model of dynamics and constraints.

• A set of optimization and planning modules that consume this model and output trajectories.

• A data assimilation layer that ingests telemetry and updates parameters.

• A validation loop where predicted and observed performance is routinely compared.

Treat that feedback loop as a code for trajectories: one that continuously compresses iteration times, explores non intuitive path geometries, and systematically climbs the efficiency curve toward maximum throughput in a constrained 3D space, and you have a self-organized flight path. Unlike self-organized Maps or pure hill-climbing approaches, this takes up the entire design space with the constraints before running iterations.



Tuesday, October 13, 2026

 

#!/usr/bin/env python3

"""Estimate drone altitude gain from nadir RGB video with LingBot-Map.


The script fits a ground plane to LingBot-Map world points and measures the

camera-to-plane distance for every frame. Monocular reconstruction has an

unknown global scale, so metric output requires --initial-altitude-m (or a

separate metric registration step). This is an analytics example, not a flight-

control or safety-critical altitude source.


Run from any directory after cloning/installing LingBot-Map:


  python altitude_gain_lingbot.py \

      --lingbot-repo /opt/lingbot-map \

      --model-path /models/lingbot-map.pt \

      --video-path flight.mp4 \

      --initial-altitude-m 18.4 \

      --output-csv altitude.csv \

      --summary-json altitude_summary.json

"""


from __future__ import annotations


import argparse

import csv

import json

import math

import sys

from contextlib import nullcontext

from pathlib import Path

from types import SimpleNamespace


import cv2

import numpy as np

import torch



def effective_sample_fps(video_path: str, requested_fps: int, stride: int) -> float:

    cap = cv2.VideoCapture(video_path)

    source_fps = float(cap.get(cv2.CAP_PROP_FPS) or 30.0)

    cap.release()

    interval = max(1, round(source_fps / requested_fps))

    return source_fps / interval / max(1, stride)



def fit_ground_plane_ransac(

    points: np.ndarray,

    optical_axis: np.ndarray,

    camera_center: np.ndarray,

    threshold: float,

    iterations: int,

    max_tilt_deg: float,

    seed: int,

) -> tuple[np.ndarray, float, np.ndarray]:

    """Return oriented unit normal, offset, and inlier mask for n.x + d = 0."""

    if len(points) < 100:

        raise ValueError(f"Too few candidate ground points: {len(points)}")


    rng = np.random.default_rng(seed)

    cos_limit = math.cos(math.radians(max_tilt_deg))

    optical_axis = optical_axis / np.linalg.norm(optical_axis)

    best_mask = None

    best_count = -1

    best_median = np.inf


    for _ in range(iterations):

        p0, p1, p2 = points[rng.choice(len(points), 3, replace=False)]

        n = np.cross(p1 - p0, p2 - p0)

        norm = np.linalg.norm(n)

        if norm < 1e-9:

            continue

        n /= norm

        if abs(float(n @ optical_axis)) < cos_limit:

            continue

        d = -float(n @ p0)

        residuals = np.abs(points @ n + d)

        mask = residuals <= threshold

        count = int(mask.sum())

        med = float(np.median(residuals[mask])) if count else np.inf

        if count > best_count or (count == best_count and med < best_median):

            best_mask, best_count, best_median = mask, count, med


    if best_mask is None or best_count < 100:

        raise RuntimeError(

            "Ground-plane RANSAC failed. Check that the video is nadir-looking, "

            "lower --confidence-threshold, or increase --plane-threshold-ratio."

        )


    inliers = points[best_mask]

    centroid = inliers.mean(axis=0)

    _, _, vh = np.linalg.svd(inliers - centroid, full_matrices=False)

    n = vh[-1]

    n /= np.linalg.norm(n)

    d = -float(n @ centroid)


    # Orient the plane normal toward the camera side of the ground plane.

    if float(n @ camera_center + d) < 0:

        n, d = -n, -d


    residuals = np.abs(points @ n + d)

    refined_mask = residuals <= threshold

    return n, d, refined_mask



def moving_median(values: np.ndarray, window: int) -> np.ndarray:

    if window <= 1:

        return values.copy()

    window = window if window % 2 == 1 else window + 1

    radius = window // 2

    padded = np.pad(values, (radius, radius), mode="edge")

    return np.array(

        [np.median(padded[i : i + window]) for i in range(len(values))],

        dtype=np.float64,

    )



def collect_plane_points(

    world_points: np.ndarray,

    confidence: np.ndarray,

    frame_count: int,

    confidence_threshold: float,

    center_fraction: float,

    pixel_stride: int,

    max_points: int,

    seed: int,

) -> np.ndarray:

    samples = []

    for i in range(min(frame_count, world_points.shape[0])):

        pts = np.asarray(world_points[i])

        conf = np.asarray(confidence[i]).squeeze()

        if pts.ndim != 3 or pts.shape[-1] != 3:

            raise ValueError(f"Unexpected world_points[{i}] shape: {pts.shape}")

        h, w = pts.shape[:2]

        y_margin = int((1.0 - center_fraction) * h / 2.0)

        x_margin = int((1.0 - center_fraction) * w / 2.0)

        ys = slice(y_margin, max(y_margin + 1, h - y_margin), pixel_stride)

        xs = slice(x_margin, max(x_margin + 1, w - x_margin), pixel_stride)

        p = pts[ys, xs].reshape(-1, 3)

        c = conf[ys, xs].reshape(-1)

        valid = np.isfinite(p).all(axis=1) & np.isfinite(c) & (c >= confidence_threshold)

        samples.append(p[valid])


    points = np.concatenate(samples, axis=0) if samples else np.empty((0, 3))

    if len(points) > max_points:

        rng = np.random.default_rng(seed)

        points = points[rng.choice(len(points), max_points, replace=False)]

    return points.astype(np.float64, copy=False)



def build_model_args(args: argparse.Namespace) -> SimpleNamespace:

    return SimpleNamespace(

        mode=args.mode,

        model_path=args.model_path,

        image_size=args.image_size,

        patch_size=14,

        enable_3d_rope=True,

        max_frame_num=1024,

        kv_cache_sliding_window=args.kv_cache_sliding_window,

        num_scale_frames=args.num_scale_frames,

        use_sdpa=args.use_sdpa,

        camera_num_iterations=args.camera_num_iterations,

    )



def run_lingbot(args: argparse.Namespace):

    repo = Path(args.lingbot_repo).resolve()

    if not (repo / "demo.py").is_file():

        raise FileNotFoundError(f"LingBot-Map demo.py not found under {repo}")

    sys.path.insert(0, str(repo))

    from demo import load_images, load_model, postprocess


    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    if device.type != "cuda":

        raise RuntimeError("This example expects a CUDA GPU for LingBot-Map inference.")


    images, paths, _ = load_images(

        video_path=args.video_path,

        fps=args.fps,

        stride=args.stride,

        first_k=args.first_k,

        image_size=args.image_size,

        patch_size=14,

    )

    if images.shape[0] < max(3, args.num_scale_frames):

        raise ValueError("The sampled video contains too few frames for reconstruction.")


    model_args = build_model_args(args)

    model = load_model(model_args, device)

    dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16

    if getattr(model, "aggregator", None) is not None:

        model.aggregator = model.aggregator.to(dtype=dtype)

    images = images.to(device)


    frame_count = int(images.shape[0])

    keyframe_interval = args.keyframe_interval

    if keyframe_interval is None:

        keyframe_interval = math.ceil(frame_count / 320) if args.mode == "streaming" and frame_count > 320 else 1


    output_device = torch.device("cpu") if args.offload_to_cpu else None

    with torch.no_grad(), torch.autocast(device_type="cuda", dtype=dtype):

        if args.mode == "streaming":

            predictions = model.inference_streaming(

                images,

                num_scale_frames=args.num_scale_frames,

                keyframe_interval=keyframe_interval,

                output_device=output_device,

            )

        else:

            predictions = model.inference_windowed(

                images,

                window_size=args.window_size,

                overlap_size=args.overlap_size,

                overlap_keyframes=args.overlap_keyframes,

                num_scale_frames=args.num_scale_frames,

                keyframe_interval=keyframe_interval,

                output_device=output_device,

            )


    if args.offload_to_cpu:

        images_for_post = predictions.get("images")

        if images_for_post is None:

            raise KeyError("LingBot-Map did not return images while CPU offload was enabled.")

    else:

        images_for_post = images

    predictions, _ = postprocess(predictions, images_for_post)

    return predictions, paths, keyframe_interval



def parse_args() -> argparse.Namespace:

    p = argparse.ArgumentParser(

        description="Estimate relative or metric drone altitude gain using LingBot-Map."

    )

    p.add_argument("--lingbot-repo", required=True, help="Path to cloned Robbyant/lingbot-map")

    p.add_argument("--model-path", required=True, help="Path to lingbot-map.pt")

    p.add_argument("--video-path", required=True, help="Nadir/top-down drone video")

    p.add_argument("--output-csv", default="altitude_gain.csv")

    p.add_argument("--summary-json", default="altitude_summary.json")

    p.add_argument(

        "--initial-altitude-m",

        type=float,

        default=None,

        help="Known camera height above the fitted ground plane at the baseline frames; enables metres",

    )

    p.add_argument("--fps", type=int, default=10, help="Requested video sampling rate")

    p.add_argument("--stride", type=int, default=1, help="Additional stride after FPS sampling")

    p.add_argument("--first-k", type=int, default=None)

    p.add_argument("--image-size", type=int, default=518)

    p.add_argument("--mode", choices=("streaming", "windowed"), default="streaming")

    p.add_argument("--num-scale-frames", type=int, default=8)

    p.add_argument("--keyframe-interval", type=int, default=None)

    p.add_argument("--kv-cache-sliding-window", type=int, default=64)

    p.add_argument("--window-size", type=int, default=128)

    p.add_argument("--overlap-size", type=int, default=16)

    p.add_argument("--overlap-keyframes", type=int, default=16)

    p.add_argument("--camera-num-iterations", type=int, default=4)

    p.add_argument("--use-sdpa", action="store_true", help="Use PyTorch SDPA instead of FlashInfer")

    p.add_argument(

        "--offload-to-cpu",

        action=argparse.BooleanOptionalAction,

        default=True,

        help="Offload predictions to CPU during inference",

    )

    p.add_argument("--plane-frames", type=int, default=8)

    p.add_argument("--baseline-frames", type=int, default=3)

    p.add_argument("--confidence-threshold", type=float, default=1.5)

    p.add_argument("--center-fraction", type=float, default=0.8)

    p.add_argument("--pixel-stride", type=int, default=4)

    p.add_argument("--max-plane-points", type=int, default=30000)

    p.add_argument(

        "--plane-threshold-ratio",

        type=float,

        default=0.01,

        help="RANSAC distance tolerance as a fraction of initial camera-to-point range",

    )

    p.add_argument("--ransac-iterations", type=int, default=750)

    p.add_argument("--max-plane-tilt-deg", type=float, default=45.0)

    p.add_argument("--smooth-window", type=int, default=5)

    p.add_argument("--seed", type=int, default=7)

    return p.parse_args()



def main() -> None:

    args = parse_args()

    predictions, paths, keyframe_interval = run_lingbot(args)


    required = {"extrinsic", "world_points", "world_points_conf"}

    missing = required.difference(predictions)

    if missing:

        raise KeyError(f"LingBot-Map output is missing: {sorted(missing)}")


    extrinsic = np.asarray(predictions["extrinsic"])

    world_points = np.asarray(predictions["world_points"])

    confidence = np.asarray(predictions["world_points_conf"])

    if extrinsic.ndim == 4 and extrinsic.shape[0] == 1:

        extrinsic = extrinsic[0]

    if world_points.ndim == 5 and world_points.shape[0] == 1:

        world_points = world_points[0]

    if confidence.ndim == 4 and confidence.shape[0] == 1:

        confidence = confidence[0]


    camera_centers = extrinsic[:, :3, 3].astype(np.float64)

    camera_forward = extrinsic[:, :3, 2].astype(np.float64)

    mean_forward = camera_forward[: min(args.plane_frames, len(camera_forward))].mean(axis=0)


    plane_points = collect_plane_points(

        world_points,

        confidence,

        args.plane_frames,

        args.confidence_threshold,

        args.center_fraction,

        args.pixel_stride,

        args.max_plane_points,

        args.seed,

    )

    initial_range = np.median(np.linalg.norm(plane_points - camera_centers[0], axis=1))

    threshold = max(1e-6, args.plane_threshold_ratio * float(initial_range))

    normal, offset, inliers = fit_ground_plane_ransac(

        plane_points,

        mean_forward,

        camera_centers[0],

        threshold,

        args.ransac_iterations,

        args.max_plane_tilt_deg,

        args.seed,

    )


    altitude_model = camera_centers @ normal + offset

    altitude_model = moving_median(altitude_model, args.smooth_window)

    baseline_n = min(max(1, args.baseline_frames), len(altitude_model))

    baseline_model = float(np.median(altitude_model[:baseline_n]))

    if baseline_model <= 0:

        raise RuntimeError("Estimated baseline camera-to-ground distance is non-positive.")


    if args.initial_altitude_m is not None:

        scale = args.initial_altitude_m / baseline_model

        altitude = altitude_model * scale

        baseline = float(np.median(altitude[:baseline_n]))

        unit = "m"

    else:

        scale = 1.0

        altitude = altitude_model

        baseline = baseline_model

        unit = "lingbot_scale_units"

    gain = altitude - baseline


    sample_fps = effective_sample_fps(args.video_path, args.fps, args.stride)

    timestamps = np.arange(len(altitude), dtype=np.float64) / sample_fps


    output_csv = Path(args.output_csv)

    output_csv.parent.mkdir(parents=True, exist_ok=True)

    with output_csv.open("w", newline="", encoding="utf-8") as f:

        writer = csv.writer(f)

        writer.writerow(

            ["frame_index", "timestamp_s", "source_frame", f"altitude_{unit}", f"gain_{unit}"]

        )

        for i, (ts, alt, delta) in enumerate(zip(timestamps, altitude, gain)):

            source = paths[i] if i < len(paths) else ""

            writer.writerow([i, f"{ts:.6f}", source, f"{alt:.9f}", f"{delta:.9f}"])


    plane_residuals = np.abs(plane_points @ normal + offset)

    summary = {

        "video": str(Path(args.video_path).resolve()),

        "frames_processed": int(len(altitude)),

        "effective_sample_fps": sample_fps,

        "mode": args.mode,

        "keyframe_interval": keyframe_interval,

        "unit": unit,

        "metric_scale": scale if args.initial_altitude_m is not None else None,

        "baseline_altitude": baseline,

        "final_altitude": float(altitude[-1]),

        "final_gain": float(gain[-1]),

        "maximum_gain": float(np.max(gain)),

        "minimum_gain": float(np.min(gain)),

        "ground_plane_normal_lingbot_world": normal.tolist(),

        "ground_plane_offset_lingbot_world": offset,

        "plane_candidate_points": int(len(plane_points)),

        "plane_inlier_ratio": float(np.mean(inliers)),

        "plane_median_absolute_residual_model_units": float(np.median(plane_residuals[inliers])),

        "notes": [

            "Metric altitude is scaled from the supplied initial camera height above the fitted plane.",

            "The estimate assumes the visible reference surface is approximately static and planar.",

            "Do not use this output for navigation, obstacle avoidance, or regulatory altitude reporting.",

        ],

    }

    summary_path = Path(args.summary_json)

    summary_path.parent.mkdir(parents=True, exist_ok=True)

    summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")


    print(json.dumps(summary, indent=2))

    print(f"Wrote {output_csv}")

    print(f"Wrote {summary_path}")



if __name__ == "__main__":

    main()