Thursday, October 22, 2026

 #! /usr/bin/python

# pip install fastapi uvicorn numpy scipy pyproj opencv-python

# app.py

# NEXT STEPS:

# Replace the in memory CAMERAS, OBSERVATIONS, and LANDMARKS with Postgres/PostGIS or a vector DB for persistence and scale.

# Swap the SciPy BA prototype for a production BA (Ceres/g2o) for robust global optimization.

# Integrate Grounding DINO / GLIP and MiDaS in a separate inference worker that calls /observe with pixel coordinates and timestamps.

# Add authentication, request validation, and provenance logging (signed observation receipts) for commercial use.

# Add unit tests for linear_triangulation, reprojection error, and the API endpoints.

"""

Minimal geometry-engine microservice (FastAPI) illustrating:

- register_camera

- observe

- triangulate

- solve_pnp

- bundle_adjust

- georeference

- query_landmarks

- explain


This is a prototype scaffold using in-memory stores and simple numeric routines.

Replace or extend numeric solvers and storage for production use.

"""

from typing import List, Dict, Any, Optional

from fastapi import FastAPI, HTTPException

from pydantic import BaseModel

import numpy as np

import uuid

import math


# Optional imports (wrap to provide clear error if missing)

try:

    import cv2

except Exception:

    cv2 = None


try:

    from scipy.optimize import least_squares

except Exception:

    least_squares = None


try:

    from pyproj import Transformer

except Exception:

    Transformer = None


app = FastAPI(title="Geometry Engine Prototype")


# -------------------------

# Data models and stores

# -------------------------

class CameraSpec(BaseModel):

    camera_id: str

    intrinsics: List[float] # fx, fy, cx, cy

    # extrinsics: rotation (3x3 flattened) and translation (3)

    rotation: Optional[List[float]] = None

    translation: Optional[List[float]] = None


class Observation(BaseModel):

    camera_id: str

    frame_id: str

    timestamp: float

    bbox: Optional[List[float]] = None # [x1,y1,x2,y2]

    pixel: Optional[List[float]] = None # [u, v] single point

    label: Optional[str] = None

    confidence: Optional[float] = None


# In-memory stores

CAMERAS: Dict[str, Dict[str, Any]] = {}

OBSERVATIONS: Dict[str, Dict[str, Any]] = {}

LANDMARKS: Dict[str, Dict[str, Any]] = {}


# -------------------------

# Utilities: linear algebra

# -------------------------

def intrinsics_matrix(fx, fy, cx, cy):

    K = np.array([[fx, 0, cx],

                  [0, fy, cy],

                  [0, 0, 1]], dtype=float)

    return K


def rt_to_proj(R: np.ndarray, t: np.ndarray, K: np.ndarray):

    """Return 3x4 projection matrix P = K [R | t]"""

    Rt = np.hstack((R, t.reshape(3,1)))

    return K @ Rt


def linear_triangulation(proj_mats: List[np.ndarray], pixels: List[np.ndarray]):

    """

    Linear triangulation (DLT) for N views.

    proj_mats: list of 3x4 projection matrices

    pixels: list of [u, v] pixel coordinates

    Returns 3D point in homogeneous coordinates (x, y, z)

    """

    A = []

    for P, (u, v) in zip(proj_mats, pixels):

        # rows: u * P[2,:] - P[0,:]; v * P[2,:] - P[1,:]

        A.append(u * P[2,:] - P[0,:])

        A.append(v * P[2,:] - P[1,:])

    A = np.vstack(A)

    # Solve A X = 0 via SVD

    _, _, Vt = np.linalg.svd(A)

    X = Vt[-1]

    X = X / X[3]

    return X[:3]


def reprojection_error_point(point3d, proj_mats, pixels):

    errs = []

    Xh = np.hstack((point3d, 1.0))

    for P, (u, v) in zip(proj_mats, pixels):

        proj = P @ Xh

        proj = proj / proj[2]

        errs.append(proj[0] - u)

        errs.append(proj[1] - v)

    return np.array(errs)


# -------------------------

# API endpoints

# -------------------------

@app.post("/register_camera")

def register_camera(spec: CameraSpec):

    """

    Register a camera with intrinsics and optional extrinsics.

    rotation: 9 floats (row-major) for 3x3 R

    translation: 3 floats for t (meters)

    """

    cam_id = spec.camera_id

    fx, fy, cx, cy = spec.intrinsics

    K = intrinsics_matrix(fx, fy, cx, cy)

    if spec.rotation is not None and spec.translation is not None:

        R = np.array(spec.rotation, dtype=float).reshape(3,3)

        t = np.array(spec.translation, dtype=float)

    else:

        R = np.eye(3)

        t = np.zeros(3)

    CAMERAS[cam_id] = {"K": K, "R": R, "t": t}

    return {"camera_id": cam_id, "status": "registered"}


@app.post("/observe")

def observe(obs: Observation):

    """

    Ingest an observation (pixel or bbox) from a camera frame.

    Returns an observation_id.

    """

    if obs.camera_id not in CAMERAS:

        raise HTTPException(status_code=400, detail="camera not registered")

    obs_id = str(uuid.uuid4())

    OBSERVATIONS[obs_id] = obs.dict()

    OBSERVATIONS[obs_id]["obs_id"] = obs_id

    return {"obs_id": obs_id}


@app.post("/triangulate")

def triangulate(observation_ids: List[str]):

    """

    Triangulate a landmark from multiple observations.

    Requires at least two observations with pixel coordinates.

    Returns landmark_id, local_position (x,y,z), and reprojection error.

    """

    if len(observation_ids) < 2:

        raise HTTPException(status_code=400, detail="need at least two observations")

    proj_mats = []

    pixels = []

    for oid in observation_ids:

        if oid not in OBSERVATIONS:

            raise HTTPException(status_code=404, detail=f"observation {oid} not found")

        o = OBSERVATIONS[oid]

        cam = CAMERAS.get(o["camera_id"])

        if cam is None:

            raise HTTPException(status_code=400, detail=f"camera {o['camera_id']} not registered")

        if o.get("pixel") is None:

            # fallback: use bbox center if pixel not provided

            if o.get("bbox") is None:

                raise HTTPException(status_code=400, detail=f"observation {oid} has no pixel or bbox")

            x1,y1,x2,y2 = o["bbox"]

            u = (x1 + x2) / 2.0

            v = (y1 + y2) / 2.0

        else:

            u, v = o["pixel"]

        K = cam["K"]

        R = cam["R"]

        t = cam["t"]

        P = rt_to_proj(R, t, K)

        proj_mats.append(P)

        pixels.append((u, v))

    # Linear triangulation

    point3d = linear_triangulation(proj_mats, pixels)

    # Optionally refine with small BA if scipy available

    if least_squares is not None:

        def fun(x):

            return reprojection_error_point(x, proj_mats, pixels)

        res = least_squares(fun, point3d, verbose=0)

        point3d = res.x

        reproj_err = float(np.linalg.norm(res.fun) / math.sqrt(len(res.fun)/2))

    else:

        reproj_err = float(np.linalg.norm(reprojection_error_point(point3d, proj_mats, pixels)))

    landmark_id = str(uuid.uuid4())

    LANDMARKS[landmark_id] = {

        "landmark_id": landmark_id,

        "position": point3d.tolist(),

        "observations": observation_ids,

        "reprojection_error": reproj_err

    }

    return {"landmark_id": landmark_id, "position": point3d.tolist(), "reprojection_error": reproj_err}


@app.post("/solve_pnp")

def solve_pnp(payload: Dict[str, Any]):

    """

    Solve PnP for a known 3D landmark and pixel correspondences.

    payload: { camera_id, object_points: [[x,y,z],...], image_points: [[u,v],...], use_extrinsic_guess: bool }

    """

    if cv2 is None:

        raise HTTPException(status_code=500, detail="OpenCV not available for solvePnP")

    cam_id = payload.get("camera_id")

    cam = CAMERAS.get(cam_id)

    if cam is None:

        raise HTTPException(status_code=400, detail="camera not registered")

    obj_pts = np.array(payload.get("object_points", []), dtype=float)

    img_pts = np.array(payload.get("image_points", []), dtype=float)

    if obj_pts.shape[0] < 4:

        # PnP can work with 4+ points for robust solve

        pass

    K = cam["K"]

    dist = np.zeros(5)

    success, rvec, tvec = cv2.solvePnP(obj_pts, img_pts, K, dist, flags=cv2.SOLVEPNP_ITERATIVE)

    if not success:

        raise HTTPException(status_code=500, detail="solvePnP failed")

    R, _ = cv2.Rodrigues(rvec)

    t = tvec.flatten()

    return {"rotation": R.tolist(), "translation": t.tolist()}


@app.post("/bundle_adjust")

def bundle_adjust(payload: Dict[str, Any]):

    """

    Small bundle-adjustment prototype.

    payload: { landmarks: {id: [x,y,z]}, poses: {cam_id: {R_flat, t}}, observations: [{landmark_id, cam_id, pixel: [u,v]}] }

    Returns optimized landmarks and poses (local).

    """

    if least_squares is None:

        raise HTTPException(status_code=500, detail="scipy least_squares not available")

    landmarks = payload.get("landmarks", {})

    poses = payload.get("poses", {})

    observations = payload.get("observations", [])


    # Flatten variables: landmark positions only (simple prototype)

    lm_ids = list(landmarks.keys())

    x0 = []

    for lid in lm_ids:

        x0.extend(landmarks[lid])

    x0 = np.array(x0, dtype=float)


    # Build projection matrices from poses dict (assume intrinsics known per camera)

    proj_mats = {}

    for cam_id, p in poses.items():

        cam = CAMERAS.get(cam_id)

        if cam is None:

            raise HTTPException(status_code=400, detail=f"camera {cam_id} not registered")

        R = np.array(p["R_flat"], dtype=float).reshape(3,3)

        t = np.array(p["t"], dtype=float)

        K = cam["K"]

        proj_mats[cam_id] = rt_to_proj(R, t, K)


    def residuals(x):

        res = []

        # unpack landmarks

        pts = x.reshape(-1,3)

        for obs in observations:

            lid = obs["landmark_id"]

            cam_id = obs["cam_id"]

            pixel = obs["pixel"]

            idx = lm_ids.index(lid)

            P = proj_mats[cam_id]

            Xh = np.hstack((pts[idx], 1.0))

            proj = P @ Xh

            proj = proj / proj[2]

            res.append(proj[0] - pixel[0])

            res.append(proj[1] - pixel[1])

        return np.array(res)


    res = least_squares(residuals, x0, verbose=0)

    optimized = res.x.reshape(-1,3)

    out = {}

    for i, lid in enumerate(lm_ids):

        out[lid] = optimized[i].tolist()

    return {"optimized_landmarks": out, "cost": float(res.cost)}


@app.post("/georeference")

def georeference(payload: Dict[str, Any]):

    """

    Georeference a local landmark position using a known reference.

    payload: { landmark_id, reference_local: [x,y,z], reference_latlon: [lat, lon, alt] }

    Returns lat/lon/alt for the landmark.

    Note: This uses a simple similarity transform (2D Procrustes on X,Y) and pyproj for conversions.

    """

    if Transformer is None:

        raise HTTPException(status_code=500, detail="pyproj not available for georeference")

    lid = payload.get("landmark_id")

    ref_local = np.array(payload.get("reference_local"), dtype=float)

    ref_latlon = payload.get("reference_latlon") # [lat, lon, alt]

    if lid not in LANDMARKS:

        raise HTTPException(status_code=404, detail="landmark not found")

    # Convert reference latlon to local meters using pyproj (WGS84 -> local Easting/Northing via azimuthal equidistant)

    lat0, lon0, alt0 = ref_latlon[0], ref_latlon[1], ref_latlon[2] if len(ref_latlon) > 2 else 0.0

    # Use an azimuthal equidistant projection centered at reference

    proj_to_m = Transformer.from_crs("EPSG:4326", f"+proj=aeqd +lat_0={lat0} +lon_0={lon0} +units=m +datum=WGS84", always_xy=True)

    # Landmark local position (our engine's local coords)

    local_pos = np.array(LANDMARKS[lid]["position"], dtype=float)

    # We assume ref_local corresponds to (0,0) in projected meters; compute offset between local_pos and ref_local

    dx_local = local_pos[:2] - ref_local[:2]

    # Convert ref_latlon to meters (should be 0,0 by construction) and add dx_local

    ref_mx, ref_my = proj_to_m.transform(lon0, lat0)

    # Add offset

    target_mx = ref_mx + dx_local[0]

    target_my = ref_my + dx_local[1]

    # Convert back to latlon

    proj_from_m = Transformer.from_crs(f"+proj=aeqd +lat_0={lat0} +lon_0={lon0} +units=m +datum=WGS84", "EPSG:4326", always_xy=True)

    lon_t, lat_t = proj_from_m.transform(target_mx, target_my)

    alt_t = float(local_pos[2]) + (ref_latlon[2] if len(ref_latlon) > 2 else 0.0)

    return {"lat": lat_t, "lon": lon_t, "alt": alt_t}


@app.get("/query_landmarks")

def query_landmarks():

    """Return all landmarks (simple query)."""

    return {"landmarks": list(LANDMARKS.values())}


@app.get("/explain/{landmark_id}")

def explain(landmark_id: str):

    """Return supporting observations and reprojection error for a landmark."""

    lm = LANDMARKS.get(landmark_id)

    if lm is None:

        raise HTTPException(status_code=404, detail="landmark not found")

    obs = [OBSERVATIONS[oid] for oid in lm["observations"] if oid in OBSERVATIONS]

    return {"landmark": lm, "observations": obs}


# -------------------------

# Example quick-run (if executed directly)

# -------------------------

if __name__ == "__main__":

    import uvicorn

    print("Run with: uvicorn app:app --reload --port 8000")

    uvicorn.run(app, host="0.0.0.0", port=8000)



Wednesday, October 21, 2026

 A simple orthomosaic program for nadir drone videos:

import cv2

import numpy as np

import os



def extract_frames(video_path, output_dir, seconds_interval=1):

    """

    Extract frames every N seconds.

    """

    if not os.path.exists(output_dir):

        os.makedirs(output_dir)


    cap = cv2.VideoCapture(video_path)


    if not cap.isOpened():

        print("Error: Could not open video.")

        return []


    fps = cap.get(cv2.CAP_PROP_FPS)


    if fps <= 0:

        fps = 30


    frame_interval = int(fps * seconds_interval)


    print(

        f"Using frame interval: "

        f"{frame_interval} frames ({seconds_interval} seconds)"

    )


    frame_paths = []


    frame_count = 0

    saved_count = 0


    while True:


        ret, frame = cap.read()


        if not ret:

            break


        if frame_count % frame_interval == 0:


            path = os.path.join(

                output_dir,

                f"frame_{saved_count:04d}.jpg"

            )


            cv2.imwrite(path, frame)


            frame_paths.append(path)


            saved_count += 1


        frame_count += 1


    cap.release()


    print(f"Extracted {saved_count} frames.")


    return frame_paths



def compute_affine_transforms(frame_paths):


    orb = cv2.ORB_create(nfeatures=5000)


    bf = cv2.BFMatcher(cv2.NORM_HAMMING)


    transforms = [np.eye(3, dtype=np.float64)]


    prev_img = cv2.imread(frame_paths[0])

    prev_gray = cv2.cvtColor(prev_img, cv2.COLOR_BGR2GRAY)


    cumulative_T = np.eye(3, dtype=np.float64)


    for i in range(1, len(frame_paths)):


        print(

            f"Computing affine transform "

            f"{i}/{len(frame_paths)-1}"

        )


        curr_img = cv2.imread(frame_paths[i])

        curr_gray = cv2.cvtColor(curr_img, cv2.COLOR_BGR2GRAY)


        kp1, des1 = orb.detectAndCompute(prev_gray, None)

        kp2, des2 = orb.detectAndCompute(curr_gray, None)


        if des1 is None or des2 is None:


            print("Skipping frame: descriptors missing")


            transforms.append(cumulative_T.copy())


            prev_gray = curr_gray

            continue


        matches = bf.knnMatch(des1, des2, k=2)


        good = []


        for m, n in matches:

            if m.distance < 0.75 * n.distance:

                good.append(m)


        print(f"Good matches: {len(good)}")


        if len(good) < 200:

            print(

                f"Rejecting frame "

                f"(only {len(good)} matches)"

            )


            transforms.append(cumulative_T.copy())


            prev_gray = curr_gray

            continue


        src_pts = np.float32(

            [kp1[m.queryIdx].pt for m in good]

        ).reshape(-1, 1, 2)


        dst_pts = np.float32(

            [kp2[m.trainIdx].pt for m in good]

        ).reshape(-1, 1, 2)


        A, inliers = cv2.estimateAffinePartial2D(

            dst_pts,

            src_pts,

            method=cv2.RANSAC,

            ransacReprojThreshold=3

        )


        if A is None:


            print("Skipping frame: affine failed")


            transforms.append(cumulative_T.copy())


            prev_gray = curr_gray

            continue


        T = np.eye(3, dtype=np.float64)


        T[:2, :] = A


        cumulative_T = cumulative_T @ T


        transforms.append(cumulative_T.copy())


        prev_gray = curr_gray


    return transforms



def compute_canvas_size(frame_paths, homographies):


    first = cv2.imread(frame_paths[0])


    h, w = first.shape[:2]


    all_corners = []


    corners = np.float32([

        [0, 0],

        [w, 0],

        [w, h],

        [0, h]

    ]).reshape(-1, 1, 2)


    for H in homographies:


        transformed = cv2.perspectiveTransform(

            corners,

            H

        )


        all_corners.append(transformed)


    all_corners = np.concatenate(all_corners, axis=0)


    min_x = np.min(all_corners[:, 0, 0])

    max_x = np.max(all_corners[:, 0, 0])


    min_y = np.min(all_corners[:, 0, 1])

    max_y = np.max(all_corners[:, 0, 1])


    width = int(np.ceil(max_x - min_x))

    height = int(np.ceil(max_y - min_y))


    print("\nMosaic bounds:")

    print(f"min_x={min_x:.0f}")

    print(f"max_x={max_x:.0f}")

    print(f"min_y={min_y:.0f}")

    print(f"max_y={max_y:.0f}")

    print(f"Canvas size = {width} x {height}")


    translation = np.array([

        [1, 0, -min_x],

        [0, 1, -min_y],

        [0, 0, 1]

    ])


    return width, height, translation



def build_mosaic(

    frame_paths,

    homographies,

    canvas_width,

    canvas_height,

    translation

):


    mosaic = np.zeros(

        (

            canvas_height,

            canvas_width,

            3

        ),

        dtype=np.uint8

    )


    for idx, frame_path in enumerate(frame_paths):


        print(

            f"Warping frame "

            f"{idx+1}/{len(frame_paths)}"

        )


        img = cv2.imread(frame_path)


        H_global = translation @ homographies[idx]


        warped = cv2.warpPerspective(

            img,

            H_global,

            (

                canvas_width,

                canvas_height

            )

        )


        mask = np.any(warped > 0, axis=2)


        mosaic[mask] = warped[mask]


    return mosaic



def stitch_frames(frame_paths):


    if len(frame_paths) == 0:

        return None


    print("\nComputing cumulative homographies...")

    transforms = compute_affine_transforms(frame_paths)


    print("\nComputing canvas size...")

    width, height, translation = compute_canvas_size(

        frame_paths,

        transforms

    )


    print("\nBuilding mosaic...")

    mosaic = build_mosaic(

        frame_paths,

        transforms,

        width,

        height,

        translation

    )


    return mosaic



def main():


    VIDEO_PATH = "mainindexedvideo.mp4"


    FRAMES_DIR = "extracted_frames"


    OUTPUT_MAP = "orthomosaic.jpg"


    frame_paths = extract_frames(

        VIDEO_PATH,

        FRAMES_DIR,

        seconds_interval=1

    )


    if not frame_paths:

        return


    mosaic = stitch_frames(frame_paths)


    if mosaic is not None:


        cv2.imwrite(

            OUTPUT_MAP,

            mosaic

        )


        print(

            f"\nOrthomosaic saved as "

            f"{OUTPUT_MAP}"

        )



if __name__ == "__main__":

    main()






Tuesday, October 20, 2026

 G2VLM: Geometry Grounded Vision Language Model with Unified 3D Reconstruction and Spatial Reasoning

G2VLM is a geometry-grounded vision-language model designed to address one of the most persistent weaknesses of modern vision-language models (VLMs): spatial understanding and reasoning. While recent VLMs have achieved remarkable success in image understanding, multimodal dialogue, and visual question answering, they often struggle with tasks that require a deeper grasp of three-dimensional structure, object relationships, depth, and spatial reasoning. The authors argue that this limitation arises because most VLMs learn primarily from large collections of two-dimensional images paired with text and therefore lack an explicit mechanism for reconstructing and understanding the geometry of the physical world. 

To overcome this problem, the paper proposes a unified framework that combines 3D visual geometry learning with high-level multimodal reasoning. Rather than treating images as flat visual inputs, G2VLM learns to infer the underlying three-dimensional structure of scenes and then uses this geometric knowledge to support spatial reasoning. According to the authors, spatial intelligence depends on both identifying objects and understanding their locations and physical relationships. G2VLM is designed to model both capabilities simultaneously. 

The architecture is inspired by the neuroscience concept of the two-streams hypothesis, which proposes separate pathways for recognizing objects ("what") and understanding spatial relationships ("where"). Reflecting this idea, G2VLM uses a Mixture-of-Transformer-Experts (MoT) architecture containing two specialized components. The first is a geometric perception expert, responsible for learning 3D geometry, reconstructing point clouds, estimating depth, and predicting camera poses from image inputs. The second is a semantic perception expert, built upon a pretrained vision-language model, which performs multimodal understanding and language reasoning. Rather than operating independently, these experts share attention mechanisms that allow geometric and semantic information to interact and reinforce one another. 

A key contribution of the work is its ability to learn geometry from ordinary images and videos without requiring extensive handcrafted 3D supervision during downstream reasoning tasks. The model is trained in two stages. First, the semantic component is frozen while the geometric expert learns rich 3D representations from large-scale datasets containing geometric annotations such as depth and camera information. Next, the semantic expert is unfrozen and jointly trained with the geometric expert on spatial understanding tasks. This second stage allows the language model to incorporate learned geometric representations into its reasoning process, producing a system that can both reconstruct scenes and answer spatial questions about them. 

The geometric learning process is sophisticated and centers on reconstructing the three-dimensional structure of scenes. The model predicts pixel-aligned 3D point maps and camera poses, using losses that measure point reconstruction accuracy, camera pose estimation quality, and surface normal consistency. Through these objectives, the geometric expert acquires a detailed understanding of spatial layouts, depth relationships, and scene structure. Unlike systems focused solely on geometric reconstruction, however, G2VLM integrates these capabilities directly into a language model, enabling geometry to influence reasoning and decision-making. 

Experimental results demonstrate the effectiveness of this unified approach. On visual geometry benchmarks, G2VLM achieves performance comparable to state-of-the-art reconstruction systems such as VGGT. The model shows strong results on tasks including monocular depth estimation, point reconstruction, and camera pose estimation. For example, the paper reports an improvement in Absolute Relative Error on the Sintel depth benchmark, reducing the error from 0.335 to 0.297 compared with VGGT. 

The model also performs exceptionally well on spatial reasoning tasks. Across multiple benchmarks designed to evaluate understanding of spatial relationships and physical scene structure, G2VLM matches or exceeds the performance of significantly larger models. The paper highlights particularly strong performance on SPAR-Bench, where G2VLM surpasses GPT-4o by 18.5 percentage points. Notably, these results are achieved with a relatively compact 2-billion-parameter model, suggesting that explicit geometric grounding may be more valuable for spatial reasoning than simply scaling model size. 

An important finding from the study is the positive interaction between geometric and semantic learning. As the geometric expert improves its ability to reconstruct 3D scenes, the semantic reasoning capabilities of the model also improve. This supports the authors’ central hypothesis that robust spatial reasoning requires a dedicated understanding of geometry rather than reliance solely on language patterns or two-dimensional visual cues. 

In conclusion, the paper presents G2VLM as the first unified model that simultaneously performs high-quality 3D reconstruction and advanced spatial reasoning within a single vision-language framework. By incorporating an explicit geometric perception pathway and allowing it to interact closely with semantic reasoning, the model bridges a gap between low-level visual geometry and high-level multimodal intelligence. The results suggest that future progress in spatial intelligence, robotics, embodied AI, and potentially applications such as 3D scene editing may benefit significantly from integrating geometric understanding directly into foundation models. 

References: https://arxiv.org/html/2511.21688v1 


Monday, October 19, 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. 


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


#codingexercise: Codingexercise-10-18-2026.docx

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.