Friday, October 23, 2026

 This script uses DROID’s selected keyframes, optimized poses, and depth maps and then adds a depth-based orthographic renderer. This avoids MicMac, ODM, and commercial licenses, but it introduces two requirements: a working CUDA-enabled DROID-SLAM installation and camera intrinsics. Another variation of a solution to the problem1 we started to solve. 

#!/usr/bin/env python3 
"""DROID-SLAM keyframes + dense depths -> approximate orthographic color mosaic. 
Not a PKS implementation, not a metric/survey true orthophoto. No ODM/MicMac. 
Use --probe first. A CUDA DROID-SLAM installation and calibration are required. 
""" 
import argparse 
from functools import lru_cache 
import hashlib 
import json 
import logging 
import math 
from pathlib import Path 
import shutil 
import subprocess 
import sys 
import time 
 
import numpy as np 
LOG=logging.getLogger('droid_orthomosaic') 
VIDEO_PATH='mainindexedvideo.mp4' 
SCHEMA=1 
 
 
def save_json(path,obj): 
    path=Path(path);tmp=path.with_name(path.name+'.tmp') 
    tmp.write_text(json.dumps(obj,indent=2),encoding='utf-8');tmp.replace(path) 
 
 
def load_json(path):return json.loads(Path(path).read_text(encoding='utf-8')) 
 
 
def digest(path): 
    h=hashlib.sha256() 
    with open(path,'rb') as f: 
        for b in iter(lambda:f.read(1024*1024),b''):h.update(b) 
    return h.hexdigest() 
 
 
def positive(x,name,zero=False): 
    if not math.isfinite(x) or (x<0 if zero else x<=0):raise ValueError(name+' must be finite and '+('nonnegative' if zero else 'positive')) 
 
 
def probe_video(path): 
    import cv2 
    cap=cv2.VideoCapture(str(path)) 
    try: 
        if not cap.isOpened():raise RuntimeError('Cannot open local video: '+str(path)) 
        ok,img=cap.read() 
        if not ok:raise RuntimeError('Cannot decode first video frame') 
        fps=float(cap.get(cv2.CAP_PROP_FPS));n=float(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 
        return dict(width=img.shape[1],height=img.shape[0],reported_fps=fps,reported_frame_count=n, 
                    approximate_duration=n/fps if math.isfinite(fps) and fps>0 else None) 
    finally:cap.release() 
 
 
def extract_frames(video_path,output_dir,seconds_interval=.125,*,max_frame_size=1600,start=0,end=None): 
    """Chronological CFR sampling. Cache metadata is OUTSIDE image-only directory.""" 
    import cv2 
    positive(seconds_interval,'sampling interval');positive(start,'start',True) 
    if max_frame_size<64:raise ValueError('max_frame_size must be >=64') 
    if end is not None and (not math.isfinite(end) or end<=start):raise ValueError('end must be finite and > start') 
    video=Path(video_path).expanduser().resolve() 
    if not video.is_file():raise FileNotFoundError(str(video)) 
    folder=Path(output_dir).expanduser().resolve();folder.mkdir(parents=True,exist_ok=True) 
    manifest=folder.parent/(folder.name+'_manifest.json');st=video.stat() 
    spec=dict(schema=SCHEMA,video=str(video),size=st.st_size,mtime_ns=st.st_mtime_ns, 
              seconds_interval=seconds_interval,max_frame_size=max_frame_size,start=start,end=end) 
    if manifest.is_file(): 
        old=load_json(manifest);rows=old.get('frames',[]) 
        if old.get('spec')!=spec:raise RuntimeError('Extraction inputs/settings changed; choose new --work-dir/--frames-dir') 
        if rows and all((folder/r['name']).is_file() and digest(folder/r['name'])==r['sha256'] for r in rows): 
            return [str(folder/r['name']) for r in rows] 
        raise RuntimeError('Cached extracted frames missing/changed') 
    if any(folder.iterdir()):raise RuntimeError('Nonempty unmanaged frame directory; use --images-dir or choose a new directory') 
    cap=cv2.VideoCapture(str(video));rows=[] 
    try: 
        if not cap.isOpened():raise RuntimeError('Cannot open video') 
        fps=float(cap.get(cv2.CAP_PROP_FPS)) 
        if not math.isfinite(fps) or fps<=0:raise RuntimeError('Invalid reported FPS; inspect/transcode video; FPS is not guessed') 
        reported=float(cap.get(cv2.CAP_PROP_FRAME_COUNT));count=int(reported) if math.isfinite(reported) and reported>0 else 0 
        index=0;next_time=start 
        while cap.grab(): 
            t=index/fps 
            if end is not None and t>=end:break 
            if t+1e-9>=next_time: 
                ok,img=cap.retrieve() 
                if not ok or img is None:raise RuntimeError('Cannot decode frame '+str(index)) 
                next_time=start+(math.floor((t-start)/seconds_interval+1e-9)+1)*seconds_interval 
                h,w=img.shape[:2];s=min(1,max_frame_size/max(h,w)) 
                if s<1:img=cv2.resize(img,(max(1,round(w*s)),max(1,round(h*s))),interpolation=cv2.INTER_AREA) 
                name='frame_%010d.jpg'%index;path=folder/name 
                if not cv2.imwrite(str(path),img,[cv2.IMWRITE_JPEG_QUALITY,95]):raise RuntimeError('Cannot save '+str(path)) 
                rows.append(dict(name=name,source_frame=index,seconds=t,sha256=digest(path))) 
                if len(rows)%100==0:LOG.info('Extracted %d SLAM input frames at %.1fs',len(rows),t) 
            index+=1 
        if end is None and count and index<count-max(5,round(fps)):raise RuntimeError('Video decoding stopped unexpectedly early') 
        if len(rows)<12:raise RuntimeError('Need at least 12 sampled frames; choose a longer segment or higher --input-fps') 
        save_json(manifest,dict(spec=spec,original_fps=fps,frames=rows)) 
        return [str(folder/r['name']) for r in rows] 
    finally:cap.release() 
 
 
def list_images(path): 
    folder=Path(path).expanduser().resolve() 
    if not folder.is_dir():raise FileNotFoundError(str(folder)) 
    files=sorted((p for p in folder.iterdir() if p.suffix.lower() in ('.jpg','.jpeg','.png')),key=lambda p:p.name) 
    if len(files)<12:raise ValueError('Need at least 12 chronological input images') 
    return list(map(str,files)) 
 
 
def calibration_for_size(size,calib=None,calib_size=None,assume_hfov=None): 
    w,h=size 
    if calib is not None: 
        if assume_hfov is not None:raise ValueError('Choose --calib OR --assume-hfov') 
        if not calib_size:raise ValueError('--calib-size WIDTH HEIGHT is required with --calib') 
        v=np.asarray(np.loadtxt(calib),dtype=float).reshape(-1) 
        if len(v) not in (4,8,9,12) or not np.isfinite(v).all():raise ValueError('Calibration must contain fx fy cx cy and optional 4/5/8 OpenCV distortion coefficients') 
        cw,ch=calib_size 
        if cw<=0 or ch<=0 or abs((cw/ch)/(w/h)-1)>.005:raise ValueError('Calibration/source aspect ratios differ; use calibration for the actual video crop') 
        if v[0]<=0 or v[1]<=0 or not (0<=v[2]<cw and 0<=v[3]<ch):raise ValueError('Invalid focal length or principal point') 
        out=v.copy();out[[0,2]]*=w/cw;out[[1,3]]*=h/ch 
        return dict(values=out.tolist(),width=w,height=h,approximate=False,source_file=str(Path(calib).resolve()),calibration_size=[cw,ch]) 
    if assume_hfov is None:raise ValueError('Supply --calib camera.txt --calib-size W H, or explicitly opt into --assume-hfov DEGREES') 
    if not math.isfinite(assume_hfov) or not 20<assume_hfov<150:raise ValueError('Assumed horizontal FOV must be 20..150 degrees') 
    f=w/(2*math.tan(math.radians(assume_hfov)/2)) 
    LOG.warning('Using USER-ASSUMED intrinsics, NOT measured calibration; geometry can be wrong') 
    return dict(values=[f,f,(w-1)/2,(h-1)/2],width=w,height=h,approximate=True,assumed_hfov_degrees=assume_hfov) 
 
 
def demo_shape(h,w): 
    factor=math.sqrt(384*512/(h*w));h1=int(h*factor);w1=int(w*factor) 
    return h1,w1,h1-h1%8,w1-w1%8 
 
 
def run_logged(command,log_path,cwd=None): 
    LOG.info('Running: %s',subprocess.list2cmdline(command)) 
    with open(log_path,'a',encoding='utf-8') as log: 
        log.write('\nCOMMAND: '+json.dumps(command)+'\n');log.flush() 
        p=subprocess.Popen(command,cwd=str(cwd) if cwd else None,stdout=subprocess.PIPE,stderr=subprocess.STDOUT, 
                           text=True,encoding='utf-8',errors='replace',bufsize=1) 
        try: 
            for line in p.stdout:print(line,end='',flush=True);log.write(line);log.flush() 
            code=p.wait() 
        except BaseException: 
            p.terminate() 
            try:p.wait(timeout=15) 
            except subprocess.TimeoutExpired:p.kill();p.wait() 
            raise 
        if code:raise RuntimeError('Subprocess failed (%s); see %s'%(code,log_path)) 
 
 
def check_droid(repo,weights,droid_python,work): 
    repo=Path(repo).expanduser().resolve();weights=Path(weights).expanduser().resolve() 
    if not (repo/'demo.py').is_file() or not weights.is_file(): 
        raise ValueError('Provide an existing DROID-SLAM checkout and pretrained droid.pth') 
    work=Path(work).resolve();work.mkdir(parents=True,exist_ok=True) 
    check='import torch,lietorch,droid_backends; assert torch.cuda.is_available(), "CUDA is required"; print(torch.cuda.get_device_name(0)); print("GPU total bytes:",torch.cuda.get_device_properties(0).total_memory)' 
    run_logged([str(droid_python),'-c',check],work/'preflight.log',repo) 
 
 
CONVERT_CODE=r""" 
import sys,os,json,numpy as np,torch 
from scipy.spatial.transform import Rotation 
src,out=sys.argv[1:] 
# This is ONLY the locally generated official-demo reconstruction, never an untrusted .pt. 
try:data=torch.load(src,map_location='cpu',weights_only=True) 
except TypeError:data=torch.load(src,map_location='cpu') 
required={'tstamps','images','disps','poses','intrinsics'} 
if not required.issubset(data):raise RuntimeError('Unsupported DROID reconstruction schema') 
def a(k):return data[k].detach().cpu().numpy() 
poses=a('poses').astype(np.float64);K=a('intrinsics').astype(np.float64)*8.0 
D=a('disps').astype(np.float32);t=a('tstamps').astype(np.float64) 
N=len(t) 
if N<3 or poses.shape!=(N,7) or K.shape!=(N,4) or D.ndim!=3 or D.shape[0]!=N:raise RuntimeError('Invalid exported shapes') 
if not np.isfinite(poses).all() or not np.isfinite(K).all() or not np.isfinite(t).all():raise RuntimeError('Nonfinite poses/calibration/timestamps') 
if not np.allclose(t,np.round(t),atol=1e-4):raise RuntimeError('Expected integer demo image indexes, not physical timestamps') 
# Stored pose is world-to-camera [tx,ty,tz,qx,qy,qz,qw]. Invert to camera-to-world. 
Rcw=Rotation.from_quat(poses[:,3:]).as_matrix();Rwc=Rcw.transpose(0,2,1) 
C=-np.einsum('nij,nj->ni',Rwc,poses[:,:3]) 
T=np.tile(np.eye(4),(N,1,1));T[:,:3,:3]=Rwc;T[:,:3,3]=C 
if np.count_nonzero(np.isfinite(D)&(D>0))<100:raise RuntimeError('No usable upsampled inverse depths') 
os.makedirs(out,exist_ok=True) 
for name,array in [('camera_to_world',T),('intrinsics_full',K),('inverse_depth',D),('input_indexes',np.round(t).astype(np.int64))]: 
    np.save(os.path.join(out,name+'.npy'),array,allow_pickle=False) 
print(json.dumps({'keyframes':N,'depth_shape':list(D.shape),'pose':'camera_to_world','intrinsics':'saved intrinsics multiplied by 8'})) 
""" 
 
 
def reconstruct(frame_paths,work_dir,*,repo,weights,droid_python=sys.executable,calibration, 
                buffer=512,filter_thresh=2.4,keyframe_thresh=4.0): 
    from PIL import Image 
    repo=Path(repo).expanduser().resolve();weights=Path(weights).expanduser().resolve();work=Path(work_dir).resolve() 
    if not (repo/'demo.py').is_file() or not weights.is_file():raise ValueError('Provide real --droid-repo checkout and --weights droid.pth') 
    demo=(repo/'demo.py').read_text(encoding='utf-8') 
    for flag in ('--reconstruction_path','--disable_vis','--buffer'): 
        if flag not in demo:raise RuntimeError('Unsupported upstream demo.py interface: missing '+flag) 
    if buffer<16:raise ValueError('--buffer must be >=16') 
    positive(filter_thresh,'filter_thresh');positive(keyframe_thresh,'keyframe_thresh') 
    paths=[Path(p).expanduser().resolve() for p in frame_paths] 
    if len(paths)<12:raise ValueError('At least 12 chronological frames required') 
    for p in paths: 
        with Image.open(p) as im: 
            if im.size!=(calibration['width'],calibration['height']):raise ValueError('Mixed image sizes or calibration/image-size mismatch') 
    sources=[] 
    for relative in ('demo.py','droid_slam/depth_video.py','droid_slam/droid.py','droid_slam/motion_filter.py'): 
        p=repo/relative 
        if not p.is_file():raise RuntimeError('Unsupported repo layout: '+relative) 
        sources.append(dict(path=relative,sha256=digest(p))) 
    git=subprocess.run(['git','-C',str(repo),'rev-parse','HEAD'],capture_output=True,text=True) if shutil.which('git') else None 
    spec=dict(schema=SCHEMA,inputs=[dict(path=str(p),sha256=digest(p)) for p in paths],calibration=calibration, 
              weights_sha256=digest(weights),repo=str(repo),source_files=sources,git_commit=git.stdout.strip() if git and git.returncode==0 else None, 
              buffer=buffer,filter_thresh=filter_thresh,keyframe_thresh=keyframe_thresh,python=str(droid_python)) 
    work.mkdir(parents=True,exist_ok=True);manifest=work/'droid_manifest.json';done=work/'droid_success.json';maps=work/'reconstruction' 
    if manifest.is_file(): 
        if load_json(manifest)!=spec:raise RuntimeError('SLAM inputs/settings changed; use a NEW --work-dir') 
    else: 
        if (work/'slam_input').exists() or maps.exists():raise RuntimeError('Unmanaged reconstruction/staged images; choose a new work directory') 
        save_json(manifest,spec) 
    arrays=('camera_to_world.npy','intrinsics_full.npy','inverse_depth.npy','input_indexes.npy') 
    if done.is_file() and all((maps/n).is_file() for n in arrays): 
        hashes=load_json(done).get('hashes',{}) 
        if all(digest(maps/n)==hashes.get(n) for n in arrays):return maps 
        raise RuntimeError('Cached SLAM arrays changed; use a new work directory') 
    # GPU/dependency errors surface here before launching the expensive demo. 
    check_droid(repo,weights,droid_python,work) 
    staged=work/'slam_input';staged.mkdir(exist_ok=True) 
    expected=[] 
    for i,p in enumerate(paths): 
        target=staged/('frame_%010d'%i+p.suffix.lower());expected.append(target.name) 
        if not target.is_file() or digest(target)!=spec['inputs'][i]['sha256']:shutil.copyfile(p,target) 
    if {p.name for p in staged.iterdir()}!=set(expected):raise RuntimeError('Unexpected files in SLAM input directory') 
    calib_path=work/'calibration_staged.txt';np.savetxt(calib_path,np.asarray(calibration['values'])[None],fmt='%.12g') 
    pt=work/'reconstruction.pt';pt.unlink(missing_ok=True) 
    command=[str(droid_python),str(repo/'demo.py'),'--imagedir',str(staged),'--calib',str(calib_path), 
             '--stride','1','--weights',str(weights),'--buffer',str(buffer),'--filter_thresh',str(filter_thresh), 
             '--keyframe_thresh',str(keyframe_thresh),'--disable_vis','--reconstruction_path',str(pt)] 
    t0=time.perf_counter();run_logged(command,work/'droid.log',repo) 
    if not pt.is_file():raise RuntimeError('DROID did not produce reconstruction.pt') 
    maps.mkdir(exist_ok=True) 
    run_logged([str(droid_python),'-c',CONVERT_CODE,str(pt),str(maps)],work/'conversion.log',repo) 
    data=MapData(maps,calibration,paths) 
    if len(data.ids)>=buffer:LOG.warning('Keyframe buffer is full; inspect tracking and rerun in a new work directory with a suitable buffer') 
    keyframes=[dict(keyframe=i,input_index=int(k),source_file=str(paths[k])) for i,k in enumerate(data.ids)] 
    save_json(work/'keyframes.json',keyframes) 
    save_json(done,dict(hashes={n:digest(maps/n) for n in arrays},slam_and_conversion_seconds=time.perf_counter()-t0, 
                        retained_keyframes=len(data.ids),note='No claim that every input frame tracked successfully')) 
    return maps 
 
 
class MapData: 
    def __init__(self,maps,calibration,paths): 
        maps=Path(maps);self.T=np.load(maps/'camera_to_world.npy',mmap_mode='r');self.K=np.load(maps/'intrinsics_full.npy',mmap_mode='r') 
        self.disp=np.load(maps/'inverse_depth.npy',mmap_mode='r');self.ids=np.load(maps/'input_indexes.npy',mmap_mode='r') 
        self.paths=list(map(Path,paths));self.calibration=calibration 
        n=len(self.ids) 
        if n<3 or self.T.shape!=(n,4,4) or self.K.shape!=(n,4) or self.disp.ndim!=3 or self.disp.shape[0]!=n: 
            raise ValueError('Unsupported reconstruction array shapes') 
        if len(set(map(int,self.ids)))!=n or np.any(np.diff(self.ids)<=0) or np.any(self.ids<0) or np.any(self.ids>=len(paths)): 
            raise ValueError('Invalid keyframe/input index mapping') 
        if not np.isfinite(self.T).all() or not np.isfinite(self.K).all() or np.any(self.K[:,:2]<=0):raise ValueError('Invalid pose/intrinsics arrays') 
        h,w=calibration['height'],calibration['width'];h1,w1,hd,wd=demo_shape(h,w) 
        if self.disp.shape[1:]!=(hd,wd):raise RuntimeError('DROID demo resizing has changed; this adapter will not guess the geometry') 
        c=np.asarray(calibration['values'][:4]);expected=c*np.array([w1/w,h1/h,w1/w,h1/h]) 
        if not np.allclose(self.K,expected[None],rtol=2e-3,atol=.1):raise RuntimeError('Saved-intrinsics convention differs from inspected upstream (/8); refusing incorrect backprojection') 
        self.h1,self.w1=h1,w1;self.depth_h,self.depth_w=hd,wd 
    @lru_cache(maxsize=8) 
    def depth(self,i): 
        d=np.asarray(self.disp[i]);valid=np.isfinite(d)&(d>1e-8) 
        z=np.zeros_like(d,dtype=np.float32);z[valid]=1/d[valid] 
        if valid.any(): 
            med=float(np.median(z[valid]));valid&=(z>=med*.2)&(z<=med*5) 
        z[~valid]=0 
        return z 
 
 
def camera_frame(T): 
    views=np.asarray(T[:,:3,2],dtype=np.float64).copy();views/=np.linalg.norm(views,axis=1)[:,None] 
    mean=views.mean(axis=0);coherence=float(np.linalg.norm(mean)) 
    if coherence<.7:raise RuntimeError('Reconstructed camera directions are not consistently nadir; inspect SLAM') 
    up=-mean/coherence;x=np.asarray(T[0,:3,0]).copy();x-=up*np.dot(x,up) 
    if np.linalg.norm(x)<1e-8:raise RuntimeError('Degenerate local horizontal axis') 
    x/=np.linalg.norm(x);y=np.cross(up,x);y/=np.linalg.norm(y) 
    return np.stack([x,y,up]),np.median(T[:,:3,3],axis=0),coherence 
 
 
def backproject(u,v,z,K,T): 
    fx,fy,cx,cy=K 
    pc=np.column_stack(((u-cx)*z/fx,(v-cy)*z/fy,z)) 
    return pc@T[:3,:3].T+T[:3,3] 
 
 
def consistency(data,i,points,neighbors=2,tolerance=.06,min_support=1): 
    support=np.zeros(len(points),dtype=np.uint8) 
    for j in range(max(0,i-neighbors),min(len(data.ids),i+neighbors+1)): 
        if j==i:continue 
        pc=(points-data.T[j,:3,3])@data.T[j,:3,:3] 
        fx,fy,cx,cy=data.K[j];z=pc[:,2] 
        valid=np.isfinite(pc).all(axis=1)&(z>1e-8) 
        u=np.zeros(len(z));v=np.zeros(len(z));u[valid]=fx*pc[valid,0]/z[valid]+cx;v[valid]=fy*pc[valid,1]/z[valid]+cy 
        valid&=np.isfinite(u)&np.isfinite(v)&(u>=0)&(v>=0)&(u<data.depth_w-.5)&(v<data.depth_h-.5) 
        ix=np.zeros(len(z),dtype=np.int64);iy=np.zeros(len(z),dtype=np.int64) 
        ix[valid]=np.rint(u[valid]).astype(np.int64);iy[valid]=np.rint(v[valid]).astype(np.int64) 
        k=np.flatnonzero(valid) 
        other=data.depth(j)[iy[k],ix[k]] 
        good=(other>0)&(np.abs(other-z[k])<=tolerance*np.maximum(other,z[k])) 
        support[k[good]]+=1 
    return support>=min_support 
 
 
def sample_geometry(data,i,stride=4,border=.03,neighbors=2,tolerance=.06,min_support=1): 
    h,w=data.depth_h,data.depth_w 
    yy,xx=np.mgrid[0:h:stride,0:w:stride];u=xx.ravel();v=yy.ravel();z=data.depth(i)[v,u] 
    ok=(z>0)&(u>=border*w)&(u<(1-border)*w)&(v>=border*h)&(v<(1-border)*h) 
    points=backproject(u[ok],v[ok],z[ok],data.K[i],data.T[i]) 
    return points[consistency(data,i,points,neighbors,tolerance,min_support)] 
 
 
def pixel_plan(lo,hi,long_edge,max_megapixels): 
    if not 256<=long_edge<=20000:raise ValueError('--long-edge must be 256..20000') 
    positive(max_megapixels,'max_megapixels') 
    span=np.asarray(hi)-lo 
    if not np.isfinite(span).all() or np.any(span<=0):raise ValueError('Degenerate XY bounds') 
    # Pixel CENTERS include both extent endpoints. 
    ppu=(long_edge-1)/max(span);w=int(math.ceil(span[0]*ppu))+1;h=int(math.ceil(span[1]*ppu))+1 
    if w*h>max_megapixels*1e6:raise ValueError('Raster exceeds --max-megapixels; lower --long-edge or explicitly increase budget') 
    return dict(width=w,height=h,pixels_per_model_unit=float(ppu),xy_min=list(map(float,lo)),xy_max=list(map(float,hi))) 
 
 
def zbuffer_update(zbuf,scorebuf,rgbbuf,rows,cols,z,score,colors,height_tolerance): 
    """Deterministic per-pixel top-surface selection, then view score near that surface.""" 
    if not len(z):return 
    width=zbuf.shape[1];cell=rows.astype(np.int64)*width+cols 
    order=np.lexsort((-score,-z,cell));ordered=cell[order] 
    first=np.r_[True,ordered[1:]!=ordered[:-1]];take=order[first] 
    r,c=rows[take],cols[take];nz=z[take];ns=score[take] 
    old=zbuf[r,c];old_score=scorebuf[r,c] 
    replace=(nz>old+height_tolerance)|((np.abs(nz-old)<=height_tolerance)&(ns>old_score)) 
    k=take[replace];r,c=rows[k],cols[k] 
    zbuf[r,c]=z[k];scorebuf[r,c]=score[k];rgbbuf[r,c]=colors[k] 
 
 
def render_color_points(data,i,row_chunk=96,render_stride=1,border=.03): 
    import cv2 
    image=cv2.imread(str(data.paths[int(data.ids[i])])) 
    if image is None:raise RuntimeError('Cannot read keyframe source image') 
    cal=np.asarray(data.calibration['values']);K0=np.array([[cal[0],0,cal[2]],[0,cal[1],cal[3]],[0,0,1.]]) 
    if len(cal)>4:image=cv2.undistort(image,K0,cal[4:]) 
    h,w=image.shape[:2] 
    for y0 in range(0,h,row_chunk): 
        ys=np.arange(y0,min(h,y0+row_chunk),render_stride);xs=np.arange(0,w,render_stride) 
        yy,xx=np.meshgrid(ys,xs,indexing='ij') 
        u=xx.ravel();v=yy.ravel();ud=u*data.w1/w;vd=v*data.h1/h 
        inside=(ud>=border*data.depth_w)&(ud<(1-border)*data.depth_w)&(vd>=border*data.depth_h)&(vd<(1-border)*data.depth_h) 
        u,v,ud,vd=u[inside],v[inside],ud[inside],vd[inside] 
        # Bilinear interpolation of the estimated depth increases sampling density, NOT geometric information. 
        z=cv2.remap(data.depth(i),ud.astype(np.float32)[None],vd.astype(np.float32)[None],cv2.INTER_LINEAR, 
                    borderMode=cv2.BORDER_CONSTANT,borderValue=0).ravel() 
        near=data.depth(i)[np.clip(np.rint(vd).astype(int),0,data.depth_h-1),np.clip(np.rint(ud).astype(int),0,data.depth_w-1)] 
        ok=(z>0)&(near>0)&(np.abs(z-near)<=.1*np.maximum(z,near)) 
        if not ok.any():continue 
        u,v,ud,vd,z=u[ok],v[ok],ud[ok],vd[ok],z[ok] 
        points=backproject(ud,vd,z,data.K[i],data.T[i]) 
        fx,fy,cx,cy=data.K[i];score=1/(1+((ud-cx)/fx)**2+((vd-cy)/fy)**2) 
        # Crop source boundary and prefer image centers; color is from source, not low-res saved demo images. 
        score*=np.minimum.reduce([ud/data.depth_w,vd/data.depth_h,1-ud/data.depth_w,1-vd/data.depth_h]) 
        yield points,score.astype(np.float32),image[v,u,::-1] 
 
 
def export_raster(output_tif,output_image,rgb,zbuf,plan,axes,origin,fill_pixels=0,preview_max_size=None): 
    import rasterio 
    from rasterio.windows import Window 
    from rasterio.transform import from_origin 
    from PIL import Image 
    valid=np.isfinite(zbuf) 
    if not valid.any():raise RuntimeError('No valid orthographic pixels after visibility filtering') 
    if fill_pixels: 
        from scipy.ndimage import distance_transform_edt 
        distance,indices=distance_transform_edt(~valid,return_indices=True) 
        fill=(~valid)&(distance<=fill_pixels) 
        rgb[fill]=rgb[indices[0][fill],indices[1][fill]] 
        valid[fill]=True 
        del distance,indices 
    h,w=valid.shape;ppu=plan['pixels_per_model_unit'];lo=plan['xy_min'];hi=plan['xy_max'] 
    # Local arbitrary-unit affine, NO CRS; pixel centers match the sampled local positions. 
    transform=from_origin(lo[0]-.5/ppu,hi[1]+.5/ppu,1/ppu,1/ppu) 
    temporary=output_tif.with_name(output_tif.stem+'.writing.tif') 
    with rasterio.open(temporary,'w',driver='GTiff',width=w,height=h,count=4,dtype='uint8', 
                       transform=transform,compress='deflate',tiled=True,blockxsize=256,blockysize=256,BIGTIFF='IF_SAFER') as dst: 
        from rasterio.enums import ColorInterp 
        dst.colorinterp=(ColorInterp.red,ColorInterp.green,ColorInterp.blue,ColorInterp.alpha) 
        for row in range(0,h,256): 
            for col in range(0,w,256): 
                hh,ww=min(256,h-row),min(256,w-col);s=np.s_[row:row+hh,col:col+ww] 
                tile=np.asarray(rgb[s]).copy();tile[~valid[s]]=255 
                rgba=np.concatenate([tile,np.where(valid[s],255,0).astype(np.uint8)[...,None]],axis=2) 
                dst.write(np.moveaxis(rgba,2,0),window=Window(col,row,ww,hh)) 
        dst.update_tags(description='DROID-SLAM approximate orthographic color mosaic; NOT survey-controlled', 
                        model_units='arbitrary',orientation='camera-derived, not geographic north', 
                        local_axes=json.dumps(axes.tolist()),local_origin=json.dumps(origin.tolist())) 
    with rasterio.open(temporary) as src: 
        if (src.width,src.height)!=(w,h):raise RuntimeError('Exported raster dimensions do not match render') 
    temporary.replace(output_tif) 
    viewing=np.asarray(rgb).copy();viewing[~valid]=255 
    image=Image.fromarray(viewing) 
    if preview_max_size: 
        positive(preview_max_size,'preview_max_size');image.thumbnail((preview_max_size,preview_max_size),Image.Resampling.LANCZOS) 
    tmp=output_image.with_name(output_image.stem+'.writing'+output_image.suffix) 
    image.save(tmp,**({'quality':95,'subsampling':0} if output_image.suffix.lower() in ('.jpg','.jpeg') else {}));tmp.replace(output_image) 
    return dict(width=w,height=h,viewing_width=image.width,viewing_height=image.height,valid_fraction=float(valid.mean())) 
 
 
def render_orthomosaic(data,work,output,*,long_edge=6000,max_megapixels=64,neighbors=2, 
                       tolerance=.06,min_support=1,render_stride=1,border=.03,fill_pixels=0,preview_max_size=None): 
    work=Path(work).resolve();output=Path(output).expanduser().resolve() 
    if output.suffix.lower() not in ('.jpg','.jpeg','.png'):raise ValueError('--output must be JPG or PNG') 
    if not 1<=neighbors<=10 or not 1<=min_support<=2*neighbors:raise ValueError('Invalid neighbor/support settings') 
    if not 0<tolerance<.5 or not 0<=border<.2:raise ValueError('Invalid depth tolerance/border') 
    if not 1<=render_stride<=8 or not 0<=fill_pixels<=5:raise ValueError('Invalid render stride/hole fill') 
    axes,origin,coherence=camera_frame(data.T);lo=np.full(2,np.inf);hi=np.full(2,-np.inf);supported=0;nonempty=0 
    for i in range(len(data.ids)): 
        p=sample_geometry(data,i,4,border,neighbors,tolerance,min_support) 
        if len(p): 
            local=(p-origin)@axes.T;lo=np.minimum(lo,local[:,:2].min(axis=0));hi=np.maximum(hi,local[:,:2].max(axis=0));supported+=len(p);nonempty+=1 
    if supported<100 or nonempty<3:raise RuntimeError('Insufficient cross-view-consistent geometry; do not lower thresholds blindly; inspect calibration/tracking') 
    # Small padding: bounds came from sampled geometry, not necessarily exact surface perimeter. 
    pad=(hi-lo)*.01;lo-=pad;hi+=pad 
    plan=pixel_plan(lo,hi,long_edge,max_megapixels);h,w=plan['height'],plan['width'];ppu=plan['pixels_per_model_unit'] 
    LOG.info('Orthographic target %dx%d; %d retained keyframes, %d with sampled supported geometry',w,h,len(data.ids),nonempty) 
    raster_dir=work/'orthomosaic';raster_dir.mkdir(parents=True,exist_ok=True);output.parent.mkdir(parents=True,exist_ok=True) 
    # Disk-backed arrays prevent the entire z/color/score raster needing resident RAM simultaneously. 
    zbuf=np.memmap(raster_dir/'zbuffer.bin',dtype='float32',mode='w+',shape=(h,w));zbuf[:]=-np.inf 
    scorebuf=np.memmap(raster_dir/'score.bin',dtype='float32',mode='w+',shape=(h,w));scorebuf[:]=-np.inf 
    rgb=np.memmap(raster_dir/'rgb.bin',dtype='uint8',mode='w+',shape=(h,w,3));rgb[:]=255 
    scene_scale=float(np.median([np.median(data.depth(i)[data.depth(i)>0]) for i in range(len(data.ids)) if np.any(data.depth(i)>0)])) 
    t0=time.perf_counter();accepted=0 
    for i in range(len(data.ids)): 
        for points,score,color in render_color_points(data,i,render_stride=render_stride,border=border): 
            ok=consistency(data,i,points,neighbors,tolerance,min_support) 
            if not ok.any():continue 
            local=(points[ok]-origin)@axes.T;score=score[ok];color=color[ok] 
            col=np.rint((local[:,0]-lo[0])*ppu).astype(np.int64);row=np.rint((hi[1]-local[:,1])*ppu).astype(np.int64) 
            inside=(row>=0)&(col>=0)&(row<h)&(col<w) 
            if not inside.any():continue 
            zbuffer_update(zbuf,scorebuf,rgb,row[inside],col[inside],local[inside,2],score[inside],color[inside],scene_scale*.003) 
            accepted+=int(inside.sum()) 
        LOG.info('Rendered keyframe %d/%d',i+1,len(data.ids)) 
    zbuf.flush();scorebuf.flush();rgb.flush();tif=raster_dir/'orthomosaic.tif' 
    result=export_raster(tif,output,rgb,zbuf,plan,axes,origin,fill_pixels,preview_max_size) 
    save_json(work/'orthomosaic_report.json',dict(schema=SCHEMA,engine='DROID-SLAM + custom depth point-splat orthographic renderer', 
         retained_keyframes=len(data.ids),supported_sample_points=supported,keyframes_with_sampled_support=nonempty, 
         accepted_color_samples=accepted,plan=plan,result=result,view_coherence=coherence, 
         visibility=dict(neighbors=neighbors,min_other_view_support=min_support,relative_depth_tolerance=tolerance), 
         color_render_stride=render_stride,border_fraction=border,hole_fill_pixels=fill_pixels, 
         approximate_calibration=data.calibration.get('approximate',False),render_seconds=time.perf_counter()-t0, 
         local_axes=axes.tolist(),local_origin=origin.tolist(), 
         limitations='Approximate monocular depth-based orthographic mosaic; no mesh/surface optimization, radiometric equalization, ' 
                     'global seam optimization, independent loop detector, metric scale, georeferencing or accuracy guarantee. ' 
                     'Interpolated depth does not create detail; point splatting may leave holes and ghosts.')) 
    # Close mmap handles before removing scratch files (important on Windows). 
    del zbuf,scorebuf,rgb 
    for name in ('zbuffer.bin','score.bin','rgb.bin'):(raster_dir/name).unlink(missing_ok=True) 
    return tif 
 
 
def read_preview(tif,max_size=None): 
    import rasterio 
    from rasterio.enums import Resampling 
    with rasterio.open(tif) as src: 
        scale=min(1,max_size/max(src.width,src.height)) if max_size else 1 
        h,w=max(1,round(src.height*scale)),max(1,round(src.width*scale)) 
        rgb=src.read([1,2,3],out_shape=(3,h,w),resampling=Resampling.bilinear) 
        alpha=src.read(4,out_shape=(h,w),resampling=Resampling.nearest);rgb[:,alpha==0]=255 
        return np.ascontiguousarray(np.moveaxis(rgb,0,-1)[:,:,::-1]) 
 
 
def stitch_frames(frame_paths,*,work_dir='droid_work',repo,weights,calibration,long_edge=6000, 
                  droid_python=sys.executable,output='orthomosaic.jpg',**render_options): 
    """Legacy BGR-array contract with explicit new SLAM/calibration dependencies.""" 
    maps=reconstruct(frame_paths,work_dir,repo=repo,weights=weights,droid_python=droid_python,calibration=calibration) 
    data=MapData(maps,calibration,frame_paths) 
    return read_preview(render_orthomosaic(data,work_dir,output,long_edge=long_edge,**render_options)) 
 
 
def main(argv=None): 
    p=argparse.ArgumentParser(description=__doc__) 
    p.add_argument('video',nargs='?',default=VIDEO_PATH) 
    p.add_argument('--probe',action='store_true');p.add_argument('--images-dir') 
    p.add_argument('--check-droid',action='store_true',help='Test repo/weights/CUDA extensions without video extraction') 
    p.add_argument('--work-dir',default='droid_work');p.add_argument('--frames-dir') 
    p.add_argument('--droid-repo');p.add_argument('--weights');p.add_argument('--droid-python',default=sys.executable) 
    p.add_argument('--calib');p.add_argument('--calib-size',type=int,nargs=2,metavar=('WIDTH','HEIGHT')) 
    p.add_argument('--assume-hfov',type=float,help='Explicit approximate-intrinsics experiment, NOT measured camera calibration') 
    p.add_argument('--input-fps',type=float,default=8);p.add_argument('--interval',type=float,help='Overrides input-fps with seconds between samples') 
    p.add_argument('--max-frame-size',type=int,default=1600);p.add_argument('--start',type=float,default=0);p.add_argument('--end',type=float) 
    p.add_argument('--buffer',type=int,default=512);p.add_argument('--filter-thresh',type=float,default=2.4);p.add_argument('--keyframe-thresh',type=float,default=4) 
    p.add_argument('--render-only',action='store_true',help='Use saved maps/calibration; no CUDA/repo/weights needed') 
    p.add_argument('--long-edge',type=int,default=6000);p.add_argument('--max-megapixels',type=float,default=64) 
    p.add_argument('--neighbors',type=int,default=2);p.add_argument('--depth-tolerance',type=float,default=.06);p.add_argument('--min-support',type=int,default=1) 
    p.add_argument('--render-stride',type=int,default=1);p.add_argument('--border',type=float,default=.03) 
    p.add_argument('--hole-fill-pixels',type=int,default=0);p.add_argument('--preview-max-size',type=int) 
    p.add_argument('--output',default='orthomosaic.jpg') 
    a=p.parse_args(argv);logging.basicConfig(level=logging.INFO,format='%(levelname)s: %(message)s') 
    try: 
        if a.probe:print(json.dumps(probe_video(a.video),indent=2));return 0 
        if a.check_droid: 
            if not a.droid_repo or not a.weights:raise ValueError('--check-droid requires --droid-repo and --weights') 
            check_droid(a.droid_repo,a.weights,a.droid_python,a.work_dir);return 0 
        import rasterio # Missing export dependencies must fail BEFORE expensive reconstruction. 
        from PIL import Image 
        import cv2 
        if not 256<=a.long_edge<=20000:raise ValueError('--long-edge must be 256..20000') 
        positive(a.max_megapixels,'max_megapixels') 
        if a.preview_max_size is not None:positive(a.preview_max_size,'preview_max_size') 
        work=Path(a.work_dir).expanduser().resolve();work.mkdir(parents=True,exist_ok=True) 
        if a.render_only: 
            if a.calib or a.calib_size or a.assume_hfov is not None or a.images_dir or a.video!=VIDEO_PATH: 
                raise ValueError('--render-only uses saved sources/calibration; do not pass a new source or calibration') 
            spec=load_json(work/'droid_manifest.json');calibration=spec['calibration'];paths=[r['path'] for r in spec['inputs']] 
            if not all(Path(r['path']).is_file() and digest(r['path'])==r['sha256'] for r in spec['inputs']):raise RuntimeError('Source images changed/missing') 
            done=load_json(work/'droid_success.json') 
            if not all(digest(work/'reconstruction'/n)==h for n,h in done['hashes'].items()):raise RuntimeError('Cached reconstruction arrays changed') 
            maps=work/'reconstruction' 
        else: 
            if not a.droid_repo or not a.weights:raise ValueError('Supply --droid-repo and --weights, or use --render-only for an existing map') 
            if not a.calib and a.assume_hfov is None:raise ValueError('Measured intrinsics (--calib + --calib-size), or explicit --assume-hfov, are required') 
            if a.calib and (not a.calib_size or not Path(a.calib).is_file()):raise ValueError('Provide an existing --calib file AND --calib-size WIDTH HEIGHT') 
            if not (work/'droid_success.json').is_file():check_droid(a.droid_repo,a.weights,a.droid_python,work) 
            if a.images_dir: 
                if a.video!=VIDEO_PATH:raise ValueError('Choose positional video OR --images-dir') 
                paths=list_images(a.images_dir) 
            else: 
                positive(a.input_fps,'input_fps');interval=a.interval if a.interval is not None else 1/a.input_fps 
                paths=extract_frames(a.video,a.frames_dir or work/'frames',interval,max_frame_size=a.max_frame_size,start=a.start,end=a.end) 
            with Image.open(paths[0]) as im:size=im.size 
            calibration=calibration_for_size(size,a.calib,a.calib_size,a.assume_hfov) 
            maps=reconstruct(paths,work,repo=a.droid_repo,weights=a.weights,droid_python=a.droid_python, 
                      calibration=calibration,buffer=a.buffer,filter_thresh=a.filter_thresh,keyframe_thresh=a.keyframe_thresh) 
        data=MapData(maps,calibration,paths) 
        tif=render_orthomosaic(data,work,a.output,long_edge=a.long_edge,max_megapixels=a.max_megapixels, 
               neighbors=a.neighbors,tolerance=a.depth_tolerance,min_support=a.min_support,render_stride=a.render_stride, 
               border=a.border,fill_pixels=a.hole_fill_pixels,preview_max_size=a.preview_max_size) 
        LOG.info('Saved full TIFF: %s',tif);LOG.info('Saved viewing image: %s',Path(a.output).resolve()) 
        LOG.warning('Approximate depth-based orthographic mosaic, not a calibrated true orthophoto or a survey-controlled map.') 
        return 0 
    except KeyboardInterrupt:LOG.error('Interrupted; completed SLAM maps can be reused with --render-only');return 130 
    except Exception as exc:LOG.error('%s',exc);return 1 
 
 
if __name__=='__main__':raise SystemExit(main()) 


python .\generate_orthomosaic.py mainindexedvideo.mp4 --probe 

{ 

  "width": 1280, 

  "height": 720, 

  "reported_fps": 29.978690988842313, 

  "reported_frame_count": 9216.0, 

  "approximate_duration": 307.41835937499997 

} 

No comments:

Post a Comment