Creating Depth GeoTIFFs from Satellite and Aerial Imagery with Depth Anything 3: From Environment Setup to Monocular Depth Inference
Introduction
This article uses Depth Anything 3 (DA3) to perform monocular depth estimation on a single RGB GeoTIFF. No additional training is performed; we use a publicly available pretrained model.
Depth estimated from a single satellite image can be used to generate building models with geometry comparable to LOD2. For an overview of monocular depth estimation and our work on generating 3D building data from satellite imagery, see this explanatory page.
As the first step in that workflow, this article covers environment setup on Ubuntu with an NVIDIA GPU, model downloading, depth inference, GeoTIFF export, and inspection in QGIS. Building polygons, a DEM, and camera exterior orientation data are not used as inputs.
For the satellite imagery example, we used an archived WorldView-2 image with a resolution of 50 cm. Because lossy compression noise made building boundaries unclear in the original image, we first applied 2× super-resolution and image enhancement.
The resulting GeoTIFF is 2048×1955 pixels. When we performed monocular depth estimation on the entire image, the predicted boundaries appeared blurred, possibly because the whole image was downscaled internally. We therefore tried splitting the image into 300-pixel tiles, running inference on each tile, and blending the overlapping predictions.
This approach was inspired by our experience with Depth Anything 2 a little over a year earlier, when depth estimation for the same area produced a map that appeared tilted across the entire scene.
For our image, tiling produced visually sharper building boundaries, as it had with Depth Anything 2. However, this is a visual comparison, not a validation of building heights or roof slopes against measurements.
The output is relative depth. It does not directly provide building heights in meters, a DSM, or a DEM. Generating 3D building models and placing them on a DEM are separate postprocessing steps.
1. Model and Processing Pipeline
We use depth-anything/DA3MONO-LARGE. Its official model card describes it as a model for monocular relative depth estimation. It is a different model from DA3-LARGE and DA3METRIC-LARGE.
- Depth Anything 3: official repository
- DA3MONO-LARGE: model card and weights
The workflow is: read the RGB GeoTIFF → run DA3 inference → resize the depth prediction to the original pixel grid → save as GeoTIFF. The tiled version splits the image before inference and blends the depth tiles before saving.
We use the official API for DA3 itself. GeoTIFF input/output and tile blending are implemented in the additional scripts provided in this article. This is not an official DA3 geospatial tiled inference feature.
2. Environment
The environment used in this article is listed below. All commands are intended for Bash on Ubuntu.
| Item | Configuration |
|---|---|
| OS | Ubuntu 24.04 |
| GPU | NVIDIA RTX PRO 6000 Blackwell Max-Q |
| Python | 3.10 / dedicated conda environment |
| PyTorch | 2.9.1, CUDA 12.8 wheel |
| torchvision | 0.24.1 |
| xFormers | 0.0.33.post2 |
| NumPy | 1.26.4 |
| OpenCV | opencv-python 4.10.0.84 |
| MoviePy | 1.0.3 |
| Model | DA3MONO-LARGE |
| GeoTIFF input/output | Rasterio |
The memory capacity of this GPU is not a requirement for using the workflow. GPU memory requirements depend on input resolution and the environment. This article does not evaluate minimum memory requirements or processing speed on other GPUs.
For the PyTorch version combination, see the official previous versions page. For xFormers compatibility, see the 0.0.33.post2 release notes. This procedure pins a combination of major packages rather than upgrading everything to the latest version.
The “CUDA Version” shown by nvidia-smi, the Toolkit version shown by nvcc --version, and the CUDA version used by PyTorch, shown by torch.version.cuda, have different meanings. This procedure uses the CUDA 12.8 PyTorch wheel and does not replace the system CUDA Toolkit. Install a suitable NVIDIA GPU driver beforehand.
3. Create the Working Directory and Conda Environment
We assume that Miniconda or Anaconda is already available.
conda create -n da3_bw python=3.10 pip -y
conda activate da3_bw
mkdir -p ~/da3_geotiff/input ~/da3_geotiff/output ~/da3_geotiff/logs
cd ~/da3_geotiff
nvidia-smi
git --version
tmux -V
If git or tmux is unavailable, install it through Ubuntu's package manager.
sudo apt-get update
sudo apt-get install -y git tmux
Save all files listed in “Appendix: Scripts” at the end of this article directly under ~/da3_geotiff/. Copy the code from each expandable section and save it using the filename in its heading. No separate custom ZIP download or private repository is required.
| File | Purpose |
|---|---|
| constraints.txt | Pin major package versions |
| setup.sh | Install official code and dependencies |
| check_env.py | Check CUDA and imports |
| download_model.py | Download official model weights |
| infer_geotiff.py | Run inference on the whole image |
| da3_tile_io.py | Handle GeoTIFF input/output for tiled inference |
| infer_geotiff_tiled.py | Run tiled inference and blend predictions |
4. Install DA3 and Its Dependencies
conda activate da3_bw
cd ~/da3_geotiff
bash setup.sh
setup.sh performs the following steps:
1. Install the CUDA 12.8 versions of PyTorch, torchvision, and xFormers.
2. Clone the official repository into vendor/Depth-Anything-3.
3. Install the basic DA3 dependencies, Rasterio, and other required packages.
4. Run pip check, check imports, and test matrix multiplication on CUDA.
5. Save the Git commit ID and pip freeze output in logs/.
The official code is obtained with the following command:
# This is executed by setup.sh; you do not need to run it again manually.
git clone https://github.com/ByteDance-Seed/Depth-Anything-3.git vendor/Depth-Anything-3
Only the basic dependencies are installed; optional components for Gradio and 3D Gaussian output are not installed. Depth inference uses infer_gs=False.
To check the environment again, run:
python -m pip check
python check_env.py
Check that the output includes CUDA runtime: 12.8, the GPU name, and Imports and CUDA matrix multiplication: OK. These checks alone do not establish that inference with the model weights succeeds.
Although major packages are pinned, this is not a fully locked environment with every dependency and repository commit fixed in advance. If future changes cause dependency conflicts, inspect the error log and downloaded commit before removing the constraints and upgrading packages.
5. Download the DA3MONO-LARGE Weights
python download_model.py
The model is downloaded from the official Hugging Face repository, depth-anything/DA3MONO-LARGE, into models/DA3MONO-LARGE/. The script resolves a revision, downloads that specific revision, and records its ID in download_revision.txt.
cat models/DA3MONO-LARGE/download_revision.txt
cat logs/da3_git_commit.txt
These files record the versions retrieved at download time. They do not guarantee that a new download on another day will resolve to the same revision. Retain the model directory and environment records for reproducibility.
Inference loads the locally saved weights. The scripts do not upload input imagery to Hugging Face.
6. Prepare the Input GeoTIFF
The examples use input/area_rgb.tif. Place your own image at that path, or change the input path in the commands below.
The input requirements are:
- An RGB source image with an 8-bit uint8 pixel type
- A GeoTIFF with a CRS and affine transform
- Bands 1, 2, and 3 used as R, G, and B by default
- No previously estimated depth or height image as input
For 16-bit satellite imagery, first convert it to 8-bit RGB using a consistent method across the entire image. Avoid a simple astype(uint8) conversion or independent contrast adjustments for each tile. The script stops on 16-bit imagery rather than converting it automatically.
Example input inspection:
python - <<'PY'
import rasterio
with rasterio.open('input/area_rgb.tif') as src:
print('size:', src.width, src.height)
print('bands:', src.count)
print('dtype:', src.dtypes)
print('CRS:', src.crs)
print('transform:', src.transform)
print('color interpretation:', src.colorinterp)
PY
If the RGB band order differs, add --bands R_number G_number B_number when running inference. For example, use --bands 3 2 1 if R, G, and B correspond to bands 3, 2, and 1.
An additional band in a four-band image is not passed to the model. However, alpha and NoData masks recognized by Rasterio are used to identify valid pixels. Missing pixels are filled with the RGB median during inference and restored to NoData in the output. This does not eliminate their influence on predictions near missing regions.
7. Start with Whole-Image Inference
Use tmux for long-running processing.
tmux new -s da3
Run the following commands inside the session:
conda activate da3_bw
cd ~/da3_geotiff
mkdir -p output logs
set -o pipefail
python -u infer_geotiff.py \
--input input/area_rgb.tif \
--output-prefix output/area_da3_full_r1008 \
--process-res 1008 \
2>&1 | tee logs/area_da3_full_r1008.log
--process-res 1008 specifies the approximate longest side of the image passed to the model. It does not split the original image into 1008-pixel tiles. Preprocessing resizes the image without cropping and adjusts the resolution to the model's patch size.
The output depth is resized to the original image dimensions using bilinear interpolation before saving. Therefore, matching the input and output pixel dimensions does not mean that details lost during downscaled inference have been recovered.
Press Ctrl+B, then D, to detach from tmux. Reattach with:
tmux attach -t da3
8. Use 300-Pixel Tiles to Inspect Building Details
Instead of downscaling the whole image, run inference on small regions of the original image. Here we use 300-pixel crops, 100-pixel overlaps, and a model input size of 518 pixels.
python -u infer_geotiff_tiled.py \
--input input/area_rgb.tif \
--output-prefix output/area_da3_t300_o100_r518_shift \
--tile-size 300 \
--overlap 100 \
--process-res 518 \
--alignment shift \
2>&1 | tee logs/area_da3_t300_o100_r518_shift.log
| Argument | Meaning |
|---|---|
| --tile-size 300 | Crop size in the original image, normally 300×300 pixels |
| --overlap 100 | Overlap between adjacent tiles; the normal stride is 200 pixels |
| --process-res 518 | Input size used when passing a 300-pixel RGB tile to the model |
| --alignment shift | Correct only the offset using depth differences in overlapping regions |
| --output-prefix | Common filename prefix for the output files |
518 is a multiple of 14. This wrapper requires process-res to be a multiple of 14. Upscaling a 300-pixel tile to 518 pixels is a model input preparation step; it does not improve the actual ground resolution of the imagery.
For example, a 300-pixel tile covers 75×75 m in an image with a resolution of 0.25 m/pixel. At 0.5 m/pixel, it covers 150×150 m. The proportion occupied by buildings and the surrounding context therefore differ even with the same 300-pixel tile size. Equivalent accuracy is not guaranteed for other regions or image resolutions.
At image boundaries, crop positions are shifted back to keep the tiles inside the image, so the final overlap may exceed 100 pixels. If the image width or height is less than 300 pixels, the image is padded using edge pixel values. A 2048×1955 image produces 10×10 tiles, or 100 tiles in total.
The model is loaded once at the start, and tiles are processed sequentially. Each prediction is resized back to the original 300-pixel tile grid and blended into the mosaic.
Blending Modes: shift, affine, and none
| Mode | Correction | Characteristics |
|---|---|---|
| shift | Offset only | Does not rescale depth differences within each tile |
| affine | Scale and offset | Aligns scales in overlapping regions, but also changes the magnitude of local relief |
| none | No value correction | Takes a weighted average of the original predictions; differences between tiles may remain |
In this workflow, we explicitly use shift to avoid the effects of scale correction on local height differences during comparison. The supplied code defaults to affine, so do not omit this argument.
For shift alignment, let {% katex inline %}d{% endkatex %} be the current tile's depth and {% katex inline %}m{% endkatex %} the depth in the overlapping region of the existing mosaic. For valid overlapping pixels, apply:
{% katex %}
b = \operatorname{median}(m-d),\qquad d' = d+b
{% endkatex %}
For the first tile, or a tile without valid overlap, the offset is set to zero. The corrected tiles are then blended with a weighted average that assigns greater weights toward each tile's center.
{% katex %}
D(x,y)=\frac{\sum_i w_i(x,y)d_i'(x,y)}{\sum_i w_i(x,y)}
{% endkatex %}
“Alignment” here does not geometrically transform the image. It aligns the reference levels of depth values predicted at the same pixel locations.
The tiles are not independently normalized to 0–1 using their own minimum and maximum values before blending. Nevertheless, shift alignment cannot resolve true scale differences. Scan-order-dependent error accumulation and blurring caused by averaging overlapping regions may remain.
9. Run Inference on Another Area from Scratch
Change the input path and output name, then run the same command. Depth results or tiles from the previous area are not reused.
python -u infer_geotiff_tiled.py \
--input input/another_area_rgb.tif \
--output-prefix output/another_area_da3_t300_o100_r518_shift \
--tile-size 300 --overlap 100 --process-res 518 \
--alignment shift \
2>&1 | tee logs/another_area_da3_t300_o100_r518_shift.log
The scripts prevent overwriting existing outputs. When rerunning inference on the same image, specify an unused --output-prefix. The DA3 scripts in this article do not support resuming an interrupted run.
10. Understanding the Output Files
| Filename suffix | Contents | Main use |
|---|---|---|
| _depth.tif | Float32 relative depth; larger values indicate greater distance | Numerical analysis and downstream processing |
| _near01.tif | Display GeoTIFF normalized to 0–1, with nearer regions set toward 1 | Visualization in QGIS |
| _near.png | 8-bit PNG using the same display representation | Image comparisons and article illustrations |
| _metadata.json | Inference settings, GPU, image dimensions, tile corrections, and other information | Checking execution conditions |
The GeoTIFF outputs retain the input CRS, affine transform, width, and height. The output NoData value is -9999. Invalid pixel locations are preserved, but arbitrary TIFF tags and the original NoData value are not all copied unchanged.
For tiled inference, _depth.tif contains relative depth after blending corrections, not the uncorrected predictions for individual tiles. Shift or affine corrections can produce negative values. The script does not save unblended tile predictions as separate files.
The near01 Display Is Not Building Height
Let {% katex inline %}P_1,P_{99}{% endkatex %} be the 1st and 99th percentiles of the valid depth values across the blended mosaic. The display image is computed as:
{% katex %}
N=\operatorname{clip}\left(\frac{P_{99}-D}{P_{99}-P_1},0,1\right)
{% endkatex %}
Nearer regions appear white and farther regions black. This is contrast adjustment and a linear reversal of the depth direction, not inverse depth {% katex inline %}1/D{% endkatex %} or a conversion to height in meters.
Because the display range is determined independently for each image, the same brightness in near01 outputs from different regions does not represent the same depth or height. Use _depth.tif for numerical processing.
11. Inspect the Results in QGIS
1. Load the original RGB GeoTIFF and _near01.tif into QGIS.
2. Display _near01.tif using “Singleband gray.”
3. Set the minimum to 0, maximum to 1, and color ramp from black to white.
4. At the same location and scale, inspect building boundaries, roof ridges, roof surfaces, and their relationship to roads.
When displaying _depth.tif, stretch it according to its actual value range. A fixed 0–1 range may cause many pixels to appear solid white or black. A white image alone does not establish that inference failed.
Example checks for coordinates and output values:
python - <<'PY'
import numpy as np
import rasterio
source='input/area_rgb.tif'
result='output/area_da3_t300_o100_r518_shift_depth.tif'
with rasterio.open(source) as src, rasterio.open(result) as dst:
assert src.crs == dst.crs
assert src.transform == dst.transform
assert (src.width, src.height) == (dst.width, dst.height)
source_valid=np.all(src.read_masks([1,2,3]) > 0, axis=0)
depth=dst.read(1, masked=True)
assert np.array_equal(source_valid, ~np.ma.getmaskarray(depth))
values=depth.compressed()
assert values.size and np.isfinite(values).all()
print('CRS, pixel grid, and valid pixel locations: OK')
print('NoData:', dst.nodata)
print('min / median / max:', values.min(), np.median(values), values.max())
PY
If you change the RGB bands, update [1,2,3] in the checking code accordingly. These checks validate data positioning and format; they do not evaluate depth accuracy.
Example Inference Results
The image below shows the input and the monocular depth estimation result from Depth Anything 3. The input is an archived WorldView-2 image processed with 2× super-resolution.
Input image and Depth Anything 3 monocular depth estimation result:
!Input imagery and the relative depth predicted by Depth Anything 3
Left: input image after 2× super-resolution. Right: estimated relative depth displayed with nearer regions brighter.
Differences between buildings and the surrounding ground, as well as relief on some roof surfaces, appear as variations in brightness. However, broad brightness variations are also visible across the image. These may include terrain effects and errors from estimation or tile blending; this image alone cannot distinguish their causes.
Roof brightness should therefore not be interpreted directly as building height. Relative differences from the surrounding ground and agreement with measured values must be checked separately.
12. Common Problems
| Symptom | Checks and actions |
|---|---|
| CUDA GPU required / CUDA is not detected | Check nvidia-smi, python check_env.py, and the active conda environment |
| CUDA out of memory | Lower process-res for whole-image inference or use tiled inference. For tiled inference, compare 518 with another multiple of 14, such as 392. |
| xFormers version mismatch | Check the PyTorch/xFormers combination. Do not upgrade only one of them in the environment. |
| Model directory missing | Complete python download_model.py. If the model is stored elsewhere, specify --model. |
| Input must be RGB uint8 | Check that the input is the source RGB image and that band selection and data type are correct |
| Output appears entirely white or black | Check the display range. Start by displaying near01 over 0–1 and inspecting depth minimum and maximum values. |
| Depth discontinuities at tile boundaries | Compare overlap widths and blending modes. Distinguish actual height differences from blending errors. |
| Buildings appear too high or too low overall | Check whether relative depth is being interpreted as height in meters |
| Output exists | Run from scratch with an unused output prefix |
This implementation tiles model inputs, but it loads the original RGB image and mosaic arrays into CPU memory. It is not a streaming implementation that processes arbitrarily large GeoTIFFs with constant memory usage. Crop an AOI when working with large-area imagery.
13. Separate the Evaluation Questions
Visually sharp building boundaries, correct local depth geometry, and building heights that agree with measurements are separate evaluation questions.
For comparisons, use the same source image, crop extent, and display scale when examining whole-image versus tiled inference and different blending modes. Comparisons with DA2 or other models also require attention to internal input resolution and the representation of output values.
GeoTIFF export in this workflow preserves image georeferencing. It does not calibrate depth to ground-relative height or elevation. To proceed to a DSM or 3D building models, depth-to-height relationships, ground treatment, and validation against measured values must be addressed separately.
References
- Depth Anything 3 official repository
- DA3MONO-LARGE model card
- PyTorch: Previous Versions
- xFormers 0.0.33.post2: wheels for PyTorch 2.9.1
Appendix: Scripts
The following scripts are the GeoTIFF wrappers used in this article. Save each code block directly in the working directory using the indicated filename. The official model implementation and weights are not included here; obtain them from the official sources using the steps above.
constraints.txt
<details>
<summary>Full contents of constraints.txt</summary>
torch==2.9.1
torchvision==0.24.1
xformers==0.0.33.post2
numpy==1.26.4
opencv-python==4.10.0.84
moviepy==1.0.3
</details>
setup.sh
<details>
<summary>Full contents of setup.sh</summary>
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [[ "${CONDA_DEFAULT_ENV:-}" != "da3_bw" ]]; then
echo 'Activate the dedicated environment first: conda activate da3_bw' >&2
exit 1
fi
mkdir -p vendor models output logs
python -m pip install --upgrade pip
python -m pip install -c constraints.txt torch torchvision xformers \
--index-url https://download.pytorch.org/whl/cu128
if [[ ! -d vendor/Depth-Anything-3 ]]; then
git clone https://github.com/ByteDance-Seed/Depth-Anything-3.git vendor/Depth-Anything-3
fi
python -m pip install -c constraints.txt -e ./vendor/Depth-Anything-3
python -m pip install -c constraints.txt rasterio addict
python -m pip check
git -C vendor/Depth-Anything-3 rev-parse HEAD > logs/da3_git_commit.txt
python -m pip freeze > logs/environment_freeze.txt
python check_env.py
</details>
check_env.py
<details>
<summary>Full contents of check_env.py</summary>
import torch
import torchvision
import xformers
import rasterio
from depth_anything_3.api import DepthAnything3
print('torch:', torch.__version__)
print('torchvision:', torchvision.__version__)
print('xformers:', xformers.__version__)
print('CUDA runtime:', torch.version.cuda)
print('rasterio:', rasterio.__version__)
assert torch.cuda.is_available(), 'CUDA GPU is unavailable'
print('GPU:', torch.cuda.get_device_name(0))
print('Capability:', torch.cuda.get_device_capability(0))
x = torch.randn(512, 512, device='cuda')
y = x @ x
torch.cuda.synchronize()
assert torch.isfinite(y).all()
print('Imports and CUDA matrix multiplication: OK (model inference not yet tested)')
</details>
download_model.py
<details>
<summary>Full contents of download_model.py</summary>
from pathlib import Path
from huggingface_hub import HfApi, snapshot_download
root = Path(__file__).resolve().parent
repo = 'depth-anything/DA3MONO-LARGE'
info = HfApi().model_info(repo)
path = snapshot_download(
repo_id=repo, revision=info.sha,
local_dir=str(root / 'models' / 'DA3MONO-LARGE'),
allow_patterns=['*.json', '*.safetensors', '*.md', 'LICENSE*'],
)
(root / 'models' / 'DA3MONO-LARGE' / 'download_revision.txt').write_text(
f'{repo}\n{info.sha}\n', encoding='utf-8')
print('Model downloaded:', path)
</details>
infer_geotiff.py
<details>
<summary>Full contents of infer_geotiff.py</summary>
"""Single-image DA3MONO inference. Relative depth, never metric height.
Whole-image inference avoids independent tile scales. The API resizes, without
cropping; the depth is bilinearly restored to the source pixel grid. Original
CRS/affine and the intersection of RGB valid masks are retained. Unknown extra
bands are ignored; rasterio-recognized alpha masks are honored.
"""
from pathlib import Path
import argparse
import json
import time
import numpy as np
import rasterio
from PIL import Image
NODATA = -9999.0
def read_rgb(path, bands):
with rasterio.open(path) as src:
if min(bands) < 1 or max(bands) > src.count:
raise ValueError(f'Invalid RGB bands {bands}; source has {src.count} bands')
rgb = src.read(bands)
if rgb.dtype != np.uint8:
raise ValueError('Input must be RGB uint8. Convert 16-bit/float data explicitly first.')
if src.crs is None:
raise ValueError('Input has no CRS; supply a georeferenced RGB GeoTIFF')
valid = np.all(src.read_masks(bands) > 0, axis=0)
if not valid.any():
raise ValueError('Input has no valid RGB pixels')
profile = dict(driver='GTiff', width=src.width, height=src.height,
count=1, dtype='float32', crs=src.crs, transform=src.transform,
nodata=NODATA, compress='deflate', predictor=3, tiled=True,
blockxsize=256, blockysize=256, BIGTIFF='IF_SAFER')
rgb = np.moveaxis(rgb, 0, -1).copy()
# Fill masked pixels only for inference; their outputs remain NoData.
if not valid.all():
rgb[~valid] = np.median(rgb[valid], axis=0).astype(np.uint8)
print('Warning: masked areas can affect nearby predictions.', flush=True)
return rgb, valid, profile
def restore_depth(depth, height, width):
depth = np.asarray(depth, dtype=np.float32)
if depth.ndim != 2 or not np.isfinite(depth).all():
raise ValueError('Model depth must be a finite 2-D array')
if depth.shape != (height, width):
depth = np.asarray(Image.fromarray(depth).resize(
(width, height), resample=Image.Resampling.BILINEAR), dtype=np.float32)
return depth.copy()
def write_raster(path, data, valid, profile, description, tags):
arr = np.where(valid, data, NODATA).astype(np.float32)
with rasterio.open(path, 'w', **profile) as dst:
dst.write(arr, 1)
dst.set_band_description(1, description)
dst.update_tags(**tags)
v = arr[valid].astype(np.float64)
dst.update_tags(1, STATISTICS_MINIMUM=str(v.min()),
STATISTICS_MAXIMUM=str(v.max()),
STATISTICS_MEAN=str(v.mean()),
STATISTICS_STDDEV=str(v.std()),
STATISTICS_VALID_PERCENT=str(100 * valid.mean()))
def save_results(prefix, depth, valid, profile, meta):
vals = depth[valid]
if not np.isfinite(vals).all():
raise ValueError('Non-finite predictions found')
lo, hi = np.percentile(vals, [1, 99])
if hi <= lo:
raise ValueError('Depth map is effectively constant; do not treat it as a valid result')
# Display only: linear sign reversal, NOT inverse-depth or metric height.
near = np.clip((hi - depth) / (hi - lo), 0, 1).astype(np.float32)
tags = dict(model='DA3MONO-LARGE', units='relative_arbitrary_units',
inference='whole_image', process_res=str(meta['process_res']),
native_prediction_shape=str(meta['native_prediction_shape']))
write_raster(str(prefix) + '_depth.tif', depth, valid, profile,
'Relative depth (larger = farther); NOT height in meters', tags)
write_raster(str(prefix) + '_near01.tif', near, valid, profile,
'Display only: near=1 far=0; clipped P1-P99; NOT height',
dict(tags, units='normalized_display_only',
display_formula='clip((P99-depth)/(P99-P1),0,1)',
display_p1=str(lo), display_p99=str(hi)))
preview = np.zeros(depth.shape, dtype=np.uint8)
preview[valid] = np.rint(near[valid] * 255).astype(np.uint8)
Image.fromarray(preview).save(str(prefix) + '_near.png')
meta.update(depth_statistics={
'min': float(vals.min()), 'max': float(vals.max()),
'p1': float(lo), 'median': float(np.median(vals)), 'p99': float(hi)},
valid_pixels=int(valid.sum()), nodata=NODATA,
depth_units='arbitrary; not meters',
preview='near=white; P1/P99 clipping; invalid=black',
output_resampling='bilinear onto original pixel grid; no new detail added')
Path(str(prefix) + '_metadata.json').write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(meta['depth_statistics'], indent=2), flush=True)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--input', required=True)
p.add_argument('--output-prefix', required=True)
p.add_argument('--model', default=str(Path(__file__).resolve().parent / 'models/DA3MONO-LARGE'))
p.add_argument('--process-res', type=int, default=1008,
help='Inference longest side, divisible by 14; try 1008 then 2016')
p.add_argument('--bands', nargs=3, type=int, default=[1, 2, 3], metavar=('R', 'G', 'B'))
p.add_argument('--device', default='cuda:0')
args = p.parse_args()
if args.process_res < 28 or args.process_res % 14:
p.error('--process-res must be >=28 and divisible by 14')
inp = Path(args.input).expanduser().resolve()
prefix = Path(args.output_prefix).expanduser().resolve()
if not Path(args.model).is_dir():
p.error('Local model directory missing; run download_model.py first')
for suffix in ['_depth.tif', '_near01.tif', '_near.png', '_metadata.json']:
if Path(str(prefix) + suffix).exists():
p.error(f'Output exists: {prefix}{suffix}; use a new output prefix')
prefix.parent.mkdir(parents=True, exist_ok=True)
rgb, valid, profile = read_rgb(inp, args.bands)
height, width = valid.shape
if min(height, width) / max(height, width) * args.process_res < 28:
p.error('Image aspect ratio is too extreme at this process resolution')
print(f'Input: {width}x{height}, RGB bands {args.bands}; process-res={args.process_res}', flush=True)
import torch
from depth_anything_3.api import DepthAnything3
if not args.device.startswith('cuda') or not torch.cuda.is_available():
p.error('This workflow requires a CUDA GPU')
torch.cuda.set_device(torch.device(args.device))
print(f'GPU: {torch.cuda.get_device_name()}', flush=True)
model = DepthAnything3.from_pretrained(args.model).to(args.device).eval()
if model.model_name != 'da3mono-large':
raise ValueError(f'This workflow expects da3mono-large, got {model.model_name}')
start = time.time()
with torch.inference_mode():
prediction = model.inference(
[rgb], process_res=args.process_res,
process_res_method='upper_bound_resize',
infer_gs=False, export_dir=None,
)
native = np.asarray(prediction.depth[0], dtype=np.float32)
print('Native depth shape:', native.shape, flush=True)
depth = restore_depth(native, height, width)
meta = dict(input=str(inp), model=str(Path(args.model).resolve()),
model_name=model.model_name, process_res=args.process_res,
native_prediction_shape=list(native.shape), output_shape=[height, width],
crs=str(profile['crs']), transform=list(profile['transform']),
rgb_bands=args.bands, torch_version=torch.__version__,
cuda_runtime=torch.version.cuda, gpu=torch.cuda.get_device_name(),
inference_seconds=time.time() - start)
save_results(prefix, depth, valid, profile, meta)
print('Done:', prefix, flush=True)
if __name__ == '__main__':
main()
</details>
da3_tile_io.py
<details>
<summary>Full contents of da3_tile_io.py</summary>
"""Raster IO for experimental tiled relative depth mosaics."""
from pathlib import Path
import argparse
import json
import time
import numpy as np
import rasterio
from PIL import Image
NODATA = -9999.0
def read_rgb(path, bands):
with rasterio.open(path) as src:
if min(bands) < 1 or max(bands) > src.count:
raise ValueError(f'Invalid RGB bands {bands}; source has {src.count} bands')
rgb = src.read(bands)
if rgb.dtype != np.uint8:
raise ValueError('Input must be RGB uint8. Convert 16-bit/float data explicitly first.')
if src.crs is None:
raise ValueError('Input has no CRS; supply a georeferenced RGB GeoTIFF')
valid = np.all(src.read_masks(bands) > 0, axis=0)
if not valid.any():
raise ValueError('Input has no valid RGB pixels')
profile = dict(driver='GTiff', width=src.width, height=src.height,
count=1, dtype='float32', crs=src.crs, transform=src.transform,
nodata=NODATA, compress='deflate', predictor=3, tiled=True,
blockxsize=256, blockysize=256, BIGTIFF='IF_SAFER')
rgb = np.moveaxis(rgb, 0, -1).copy()
# Fill masked pixels only for inference; their outputs remain NoData.
if not valid.all():
rgb[~valid] = np.median(rgb[valid], axis=0).astype(np.uint8)
print('Warning: masked areas can affect nearby predictions.', flush=True)
return rgb, valid, profile
def restore_depth(depth, height, width):
depth = np.asarray(depth, dtype=np.float32)
if depth.ndim != 2 or not np.isfinite(depth).all():
raise ValueError('Model depth must be a finite 2-D array')
if depth.shape != (height, width):
depth = np.asarray(Image.fromarray(depth).resize(
(width, height), resample=Image.Resampling.BILINEAR), dtype=np.float32)
return depth.copy()
def write_raster(path, data, valid, profile, description, tags):
arr = np.where(valid, data, NODATA).astype(np.float32)
with rasterio.open(path, 'w', **profile) as dst:
dst.write(arr, 1)
dst.set_band_description(1, description)
dst.update_tags(**tags)
v = arr[valid].astype(np.float64)
dst.update_tags(1, STATISTICS_MINIMUM=str(v.min()),
STATISTICS_MAXIMUM=str(v.max()),
STATISTICS_MEAN=str(v.mean()),
STATISTICS_STDDEV=str(v.std()),
STATISTICS_VALID_PERCENT=str(100 * valid.mean()))
def save_results(prefix, depth, valid, profile, meta):
vals = depth[valid]
if not np.isfinite(vals).all():
raise ValueError('Non-finite predictions found')
lo, hi = np.percentile(vals, [1, 99])
if hi <= lo:
raise ValueError('Depth map is effectively constant; do not treat it as a valid result')
# Display only: linear sign reversal, NOT inverse-depth or metric height.
near = np.clip((hi - depth) / (hi - lo), 0, 1).astype(np.float32)
tags = dict(model='DA3MONO-LARGE', units='relative_arbitrary_units',
inference=meta['inference'], alignment=meta['alignment'], tile_size=str(meta['tile_size']), overlap=str(meta['overlap']), process_res=str(meta['process_res']),
native_prediction_shape=str(meta['native_prediction_shape']))
write_raster(str(prefix) + '_depth.tif', depth, valid, profile,
'Relative depth (larger = farther); NOT height in meters', tags)
write_raster(str(prefix) + '_near01.tif', near, valid, profile,
'Display only: near=1 far=0; clipped P1-P99; NOT height',
dict(tags, units='normalized_display_only',
display_formula='clip((P99-depth)/(P99-P1),0,1)',
display_p1=str(lo), display_p99=str(hi)))
preview = np.zeros(depth.shape, dtype=np.uint8)
preview[valid] = np.rint(near[valid] * 255).astype(np.uint8)
Image.fromarray(preview).save(str(prefix) + '_near.png')
meta.update(depth_statistics={
'min': float(vals.min()), 'max': float(vals.max()),
'p1': float(lo), 'median': float(np.median(vals)), 'p99': float(hi)},
valid_pixels=int(valid.sum()), nodata=NODATA,
depth_units='arbitrary; not meters',
preview='near=white; P1/P99 clipping; invalid=black',
output_resampling='bilinear onto original pixel grid; no new detail added')
Path(str(prefix) + '_metadata.json').write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(meta['depth_statistics'], indent=2), flush=True)
</details>
infer_geotiff_tiled.py
<details>
<summary>Full contents of infer_geotiff_tiled.py</summary>
"""DA3MONO independent overlapping tile inference; experimental relative-depth mosaic."""
import argparse
from pathlib import Path
import time
import numpy as np
from da3_tile_io import read_rgb, restore_depth, save_results
def starts(length, tile, overlap):
if length <= tile:
return [0]
pos = list(range(0, length - tile + 1, tile - overlap))
if pos[-1] != length - tile:
pos.append(length - tile)
return pos
def fit_overlap(x, y, mode):
"""Robust positive affine fit; reject poorly constrained scale estimates."""
x, y = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64)
if not len(x):
return 1., 0., 'unanchored'
step = max(1, len(x) // 20000)
x, y = x[::step], y[::step]
shift = float(np.median(y - x))
if mode == 'shift' or len(x) < 128:
return 1., shift, 'shift'
keep = np.ones(len(x), dtype=bool)
a, b = 1., shift
for _ in range(4):
xx, yy = x[keep], y[keep]
if len(xx) < 128 or np.std(xx) < 1e-6 * max(1., abs(np.mean(xx))):
return 1., shift, 'fallback_flat_overlap'
dx, dy = xx - xx.mean(), yy - yy.mean()
corr = np.dot(dx, dy) / max(np.linalg.norm(dx) * np.linalg.norm(dy), 1e-20)
a = float(np.dot(dx, dy) / np.dot(dx, dx))
if corr < .5 or not .25 <= a <= 4.:
return 1., shift, 'fallback_unstable_scale'
b = float(np.median(yy - a * xx))
residual = y - (a * x + b)
med = np.median(residual)
mad = np.median(np.abs(residual - med))
keep = np.abs(residual - med) <= max(3 * 1.4826 * mad, 1e-6)
return a, b, 'affine'
def blend_weight(height, width):
# Positive edge weights retain image boundary pixels.
wy = np.maximum(np.sin(np.pi * (np.arange(height) + .5) / height)**2, .02)
wx = np.maximum(np.sin(np.pi * (np.arange(width) + .5) / width)**2, .02)
return wy[:, None] * wx[None, :]
def mosaic(rgb, valid, predict, tile, overlap, alignment):
h, w = valid.shape
total = np.zeros((h, w), dtype=np.float64)
weights = np.zeros((h, w), dtype=np.float64)
records = []
ys, xs = starts(h, tile, overlap), starts(w, tile, overlap)
for y in ys:
for x in xs:
sl = np.s_[y:min(y+tile, h), x:min(x+tile, w)]
mask = valid[sl]
if not mask.any():
continue
patch = rgb[sl]
ph, pw = mask.shape
# Small images padded; normal edge tiles remain exactly tile x tile.
padded = np.pad(patch, ((0, tile-ph), (0, tile-pw), (0, 0)), mode='edge')
native = predict(padded)
depth = restore_depth(native, tile, tile)[:ph, :pw]
old_w, old_sum = weights[sl], total[sl]
common = mask & (old_w > 0)
a, b, status = 1., 0., 'none'
if alignment != 'none':
a, b, status = fit_overlap(depth[common], old_sum[common]/old_w[common], alignment)
corrected = a * depth + b
weight = blend_weight(ph, pw) * mask
old_sum += corrected * weight
old_w += weight
records.append(dict(x=x, y=y, width=pw, height=ph,
native_shape=list(np.shape(native)), scale=a, shift=b,
alignment_status=status, overlap_pixels=int(common.sum())))
print(f'Tile {len(records)}/{len(ys)*len(xs)} ({x},{y}) {status}: a={a:.4g}, b={b:.4g}', flush=True)
if np.any(valid & (weights <= 0)):
raise RuntimeError('Valid pixels not covered')
output = np.zeros((h, w), dtype=np.float32)
output[valid] = (total[valid] / weights[valid]).astype(np.float32)
return output, records
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--input', required=True)
p.add_argument('--output-prefix', required=True)
p.add_argument('--model', default=str(Path(__file__).resolve().parent/'models/DA3MONO-LARGE'))
p.add_argument('--tile-size', type=int, default=300)
p.add_argument('--overlap', type=int, default=100, help='Overlap pixels, NOT stride')
p.add_argument('--process-res', type=int, default=518, help='Model input longest side, multiple of 14')
p.add_argument('--alignment', choices=['affine', 'shift', 'none'], default='affine')
p.add_argument('--bands', nargs=3, type=int, default=[1,2,3])
p.add_argument('--device', default='cuda:0')
args = p.parse_args()
if args.tile_size < 28 or not 0 <= args.overlap < args.tile_size:
p.error('Require tile-size >=28 and 0 <= overlap < tile-size')
if args.alignment != 'none' and args.overlap == 0:
p.error('Alignment needs overlap >0; use --alignment none for nonoverlapping tiles')
if args.process_res < 28 or args.process_res % 14:
p.error('process-res must be >=28 and divisible by 14')
if not Path(args.model).is_dir():
p.error('Model directory missing; specify existing --model path')
prefix = Path(args.output_prefix).expanduser().resolve()
for suffix in ['_depth.tif','_near01.tif','_near.png','_metadata.json']:
if Path(str(prefix)+suffix).exists():
p.error(f'Output exists: {prefix}{suffix}; use a new prefix')
rgb, valid, profile = read_rgb(args.input, args.bands)
import torch
from depth_anything_3.api import DepthAnything3
if not args.device.startswith('cuda') or not torch.cuda.is_available():
p.error('CUDA GPU required')
torch.cuda.set_device(torch.device(args.device))
model = DepthAnything3.from_pretrained(args.model).to(args.device).eval()
if model.model_name != 'da3mono-large':
p.error('Expected DA3MONO-LARGE')
def predict(patch):
with torch.inference_mode():
result = model.inference([patch], process_res=args.process_res,
process_res_method='upper_bound_resize',
infer_gs=False, export_dir=None)
return np.asarray(result.depth[0], dtype=np.float32)
start = time.time()
depth, records = mosaic(rgb, valid, predict, args.tile_size, args.overlap, args.alignment)
meta = dict(input=str(Path(args.input).resolve()), model=str(Path(args.model).resolve()),
model_name=model.model_name, process_res=args.process_res,
native_prediction_shape=records[0]['native_shape'], output_shape=list(valid.shape),
crs=str(profile['crs']), transform=list(profile['transform']), rgb_bands=args.bands,
inference='independent_overlapping_tiles', tile_size=args.tile_size,
overlap=args.overlap, stride=args.tile_size-args.overlap, alignment=args.alignment,
alignment_warning='Experimental relative mosaic; scan-order drift and disconnected scales possible; not metric depth or height',
tiles=records, torch_version=torch.__version__, cuda_runtime=torch.version.cuda,
gpu=torch.cuda.get_device_name(), inference_seconds=time.time()-start)
prefix.parent.mkdir(parents=True, exist_ok=True)
save_results(prefix, depth, valid, profile, meta)
print('Done:', prefix, flush=True)
if __name__ == '__main__':
main()
</details>
Mentions
AIが書いたコード,レビューが追いつかない人へ.差分の理解を助けるツールまとめ はじめに 中部大学 機械知能研究室(研究室HP)に所属し,コンピュータビジョン(CV)分野の研究をしている者です. 最近は生成AIの発展により,実装をAIに任せることが増えました.生成AIに指示を出すとコードが一瞬で生成され,次々とプルリクエスト(PR)が立てられるようになります.一方で,その差分が意図どおりかを確かめるのは人間であるため,レビューが追いつかなくなってきました. そこで今回は,AIが書いた差分を人間が理解するのを助けるツールを,課題とあわせて紹介します. 後半では,AIが書いた同じプルリクエスト(PR)を3つのツールで実際に読み,次の3点を確かめた結果も紹介します. - 全体像をつかめるか - 変更の範囲が分かるか - 仕様の抜けに気づけるか 1. AIに実装を任せると,何が課題になるのか 数字で見る現状 Sonarが2026年1月に公開した開発者調査(回答者1,100人以上)から,2つの数字を紹介します[1]. - コミットされるコードの42%を,AIが書いている - 38%の開発者が,AIのコードのレビューは同僚のコードより手間がかかると答えている 書く作業はAIが肩代わりしてくれます.その分,確かめる作業が人間に集まっています. AIのコードがレビューしにくい4つの理由 私は,AIが書いた差分には次の4つの読みにくさがあると考えています.()内は,後半で試したPRで実際に見られた例です. 1. 量が多く,速い 差分がまとめて返ってくるので,1行ずつ読む時間が取れません.(指示は4行でしたが,返ってきたのは6ファイル,+119行の差分でした) 2. 重要な数行が,機械的な変更に埋もれる 同じ形の変更が並ぶと,本当に考えるべき箇所が目立ちません.(119行の大半はCLIのオプション追加と文書で,中心は18行の関数だけでした) 3. 前提が差分に残らない AIは指示を満たすコードを書きますが,「何を前提にしたか」は書かれません.書かれていても,正しいかは確かめられていません.(「モデルの出力はメートル」という前提は,実際には一部のモデルにしか当てはまりませんでした) 4. 説明が自信を持って書かれている コミットメッセージやPRの説明は,言い切りの形で書かれます.読む側は,それを信じたくなります.(「書き出すファイルも同じ単位になる」と書かれていましたが,一部の出力形式では成り立ちませんでした) 人間がレビューで確かめたいこと こうした差分を前にして,人間がまず知りたいことは次の3つです. | 確かめたいこと | 関係する理由 | |---|---| | 何のための変更で,全体はどうなっているか | 1(量が多い) | | どこに時間を使えばよいか | 2(埋もれる) | | 前提は正しいか.仕様の抜けはないか | 3(前提が残らない),4(説明を信じてしまう) | この3つを,人間が全部の行を読まずに確かめられるようにしたい.それが,レビュー支援ツールに期待する役割です. 2. なぜ生成AIに直接聞かず,ツールを使うのか 差分をそのままチャット形式の生成AIに貼り,「説明して」と頼む方法もあります.手軽ですが,上の課題を考えると足りない点があります. なお,この記事で紹介するツールの多くも,中身は生成AIです.違うのは,AIに「どう読ませるか」と「結果をどう見せるか」です. | | チャットに差分を貼る | レビュー支援ツール | |---|---|---| | 読ませ方 | 聞き方しだい.前提を疑うかどうかも聞き方による | 読む手順が決まっている(例:主張を「確かめる対象」として読む) | | 根拠 | どこまでがコードで確かめたことか,自分で見直す必要がある | 行番号の検証や,根拠の強さのラベルが付くものがある | | 結果の形 | 毎回変わる | 決まった形で出る.AIを使わない図なら毎回同じ | | 共有 | 自分の画面の中だけ | PRに付けば,チーム全員が同じものを見られる | 特に大きいのは,実装したAIに聞いても,同じ前提で読んでしまうことです.後半で試したPRでは,実装したClaude Codeは,PRの説明で相対深度のモデルに触れていませんでした.一方,ツアー型のツール(diff-tour)にツアーを書かせたところ,書き手は同じClaude Codeなのに,その抜けを指摘しました.今回の結果を見る限り,差を生んだのはモデルではなく,読ませ方だと考えています. ただし,チャット形式のAIに直接聞く方法とは,実際には比べていません.ここに書いたのは,今回の結果とツールの仕組みから考えた理由です. 3. レビューを助けるツールの紹介 差分の理解を助けるツールを,4つに分類しました. | 分類 | 対象 | 何をするか | 主に効く課題 | ツールの例 | |---|---|---|---|---| | PR差分型 | PRの差分 | PRに要約や図,指摘をコメントする | 全体像,時間を使う場所 | CodeRabbit,Greptile,PR-Agent | | ツアー型 | PRの差分 | 差分を読む順番に沿って解説する | 時間を使う場所,前提と仕様の抜け | diff-tour | | リポジトリ全体型 | リポジトリ全体 | Wikiや構成図を作る | 変更の前提となる全体の構造 | DeepWiki,Google Code Wiki,GitDiagram | | 静的解析型 | 関数・ファイル | AIを使わずに,構文から図を作る | 特定の関数の構造の確認 | CodeVisualizer | 次に各カテゴリごとに詳しくみていきます.なお,費用はすべて2026年10月時点のものです.★は,後半で実際に試したツールです. 3.1 PR差分型:PRを開くと要約が付いている PRに自動で要約や図,指摘を投稿するタイプです.PRを開いた時点で全体像が分かるので,レビューの入口として使いやすいです.一方,PRの差分を中心に見るので,差分の外にある前提までは確かめないことがあります. ★CodeRabbit - 何をするか:PRにAIのレビューを自動で投稿します.変更の要約(Walkthrough),シーケンス図,マージのリスク,マージ前のチェックなどが付きます. - 仕組み:GitHubなどにCodeRabbitのアプリ(GitHub App)を導入すると,PRごとにレビューが投稿されます. - 費用:今回は,14日間の試用期間中にAdvancedプランで使いました(支払いはなし).試用期間のあとも使う場合,Advancedプランを月払いにすると月額90ドル/ユーザーです(管理画面の見積もり).料金ページの表示額は月額72ドル/ユーザーでした.公開リポジトリは無料です[2]. Greptile - 何をするか:AIがPRをレビューします.レビューの深さを3段階から選べます. - 費用:Starterは無料(1人まで),Proは月額30ドル/シートです. PR-Agent - 何をするか:/describe(説明),/review(レビュー),/improve(改善案)などのコマンドで,PRを説明・レビューします. - 費用:オープンソース(MITライセンス)です.使うLLMの費用は別にかかります. 3.2 ツアー型:差分を「読む順番」に沿って解説する 差分を,ベテランが読む順番に沿って1ステップずつ解説するタイプです.どこに時間を使うかを示し,前提を問いの形で確かめていくので,仕様の抜けに気づきやすくなります.代わりに,作るのにも読むのにも時間がかかります. ★diff-tour - 何をするか:gitの差分を「ツアー」にして,ターミナルで1ステップずつ読ませます[3].答えの前に問いを出すステップもあります. - 仕組み:手元のLLMのCLI(Claude Code,Codex CLI,Gemini CLIなど)が,読み取り専用の権限でツアーを書きます.ツールは,ツアーが参照するファイルと行番号を検証し,画面のコードはgitから読み込みます.各説明には「確かめた」「読み取れる」「シニアの見方」のラベルが付き,根拠の強さが分かります[3]. - 費用:ツール自体はMITライセンスで無料です.ツアーの作成には,自分のLLMの利用枠を使います[3]. 3.3 リポジトリ全体型:変更の前提になる全体像をつかむ リポジトリ全体のWikiや構成図を作るタイプです.PRの差分は扱いませんが,初めて触るリポジトリで「この変更はどこに効くのか」を考える土台になります. DeepWiki - 何をするか:リポジトリ全体のWikiとアーキテクチャ図を自動で作ります. - 費用:公開リポジトリは無料です.非公開リポジトリには,Devinのアカウントが必要です. - 今回は,非公開リポジトリではDevinへの登録が必要で,インデックスの作成にも時間がかかるため,試していません. Google Code Wiki - 何をするか:Geminiでリポジトリの文書と図を作り,コードの変更に合わせて更新します.Wikiを使って質問に答えるチャットも付きます. - 費用:公開プレビュー中で,公開リポジトリが対象です. GitDiagram - 何をするか:GitHubのURLの hub を diagram に変えるだけで,リポジトリの構成図ができます. - 費用:無料です(MITライセンス).GitHubのトークンを渡せば,非公開リポジトリにも使えます. 3.4 静的解析型:AIを使わずに,構造を正確に見る 構文を解析して図を作るタイプです.AIを使わないので,図はコードの事実として正確で,毎回同じ結果になります.ただし,その値が何を意味するか,仕様として正しいかは分かりません. ★CodeVisualizer - 何をするか:VS Codeの拡張です.関数のフローチャートと,ファイル間の依存関係のグラフを作ります[4]. - 仕組み:Tree-sitterで構文を解析し,図にします.解析は手元で行い,コードを外部に送りません.AIでラベルを付ける機能もありますが,既定ではオフです[4]. - 費用:無料です(MITライセンス)[4]. 4. 同じPRで試してみた ここからは,★の3つのツールで,AIが書いた同じPRを読んだ結果を紹介します. 題材 Depth Anything 3 に,推論結果の距離の単位(m/mm)を切り替える機能を,Claude Codeで追加しました.指示文は次のとおりです(全文). mainから feature/unit-switch ブランチを作成し、次の機能を実装してください。 推論結果の距離の単位を、メートルとミリメートルから選べるようにしてください。 APIとCLIの両方から指定できるようにしてください。 実装が終わったら、コミットしてpushし、mainへのPRを作成してください。 できたPRは,1コミット,6ファイル(api.py,cli.py,services/backend.py,services/inference_service.py,docs/API.md,docs/CLI.md),+119 −2 でした. PRのブランチで,unit=m と unit=mm の両方で推論しました.どちらもエラーなく,npzとglbを出力できました.mmの深度は,mのおよそ1000倍(最大値で 56.4892 → 56492.3)でした.指示どおりには動いています. 私が意図していた仕様は次のとおりです.この仕様はAIには見せていません. | 分類 | 項目 | |---|---| | 変える値 | 深度(depth),カメラの並進 | | 変えない値 | 内部パラメータ,回転 | | 見落としやすい箇所 | gs出力での scale_factor による割り戻しとの整合 | | 見落としやすい箇所 | 相対深度のモデルでmmを指定したときの警告 | | 見落としやすい箇所 | npzに単位の情報を保存すること | 実行環境は,GPUがRTX 4090,モデルはDA3NESTED-GIANT-LARGE-1.1です.評価は◎・○・△・×の4段階で,私1人がつけました(5章). :::note info ライセンス:Depth Anything 3 のコードは Apache 2.0 です.モデル DA3NESTED-GIANT-LARGE-1.1 は CC BY-NC 4.0 です(リポジトリのREADMEのモデル一覧で確認). ::: 4.1 CodeRabbit リポジトリにCodeRabbitを導入し,初期設定(Review profile: CHILL)のまま使いました. !image.png !image.png !image.png - Walkthroughは,変更を「変換(api.py)」「伝達(inference_service.py,backend.py)」「CLIとドキュメント」の3層に分けて表で説明していました.6ファイルの役割がすぐに分かります.レビューの手間の目安は3/5(Moderate),約20分でした. - 説明の内容は正確でした.「深度とカメラの並進を変換」「ガウシアンは scale_factor を更新」は,意図した仕様と一致します. - 一方で,シーケンス図は distance_unit の受け渡しの流れだけでした.どこで何を変換するかは描かれていません.変えない値にも触れていませんでした. - 指摘コメントは0件,Merge Riskは「Minimal」でした.Pre-merge checksは5件すべてに通過の印が付いていました.ただし,実際に判定したのは Title check と Docstring Coverage の2件だけです.残りの3件(Description Check,Linked Issues check,Out of Scope Changes check)は,スキップされても通過の印が付いていました. - 元のリポジトリにあったCLIの不具合(未定義の変数 reference_view_strategy で落ちる)は,「このPRが原因ではない」と正しく区別していました. 4.2 diff-tour v0.5.0(Linux)を1行のコマンドでインストールしました.ツアーを作るのに専用のコマンドはありません.リポジトリで tour を実行すると変更の一覧が出るので,feature/unit-switch を選ぶだけです[3].初回だけ,表示する言語とツアーを書くLLMを選びます.今回の書き手はClaude Code(Proプランの利用枠)です. ツアーは17ステップ(6章)で,作成に約9分かかりました.READMEの目安(2〜5分)より長めです.費用はAPI価格に換算して約2.21ドルでした(Proプランの枠内なので請求はなし). ツアーは,コミットメッセージを「確かめる対象」として扱うところから始まります. !image.png 次に,6ファイルを「中心(api.py)」「機械的(cli.py など)」「文書」に分け,時間を使う場所を示します.4ステップ目は値が流れる順の図で,CodeRabbitのシーケンス図と違い,「どこで変換するか」まで描かれていました. !image.png 問い1:gs_videoでカメラとガウシアンはずれないか(6・7ステップ目) nestedのモデルでmmを選ぶと,カメラの並進と scale_factor は1000倍されますが,ガウシアンは元のままです.6ステップ目は,これでカメラとガウシアンがずれないかを問うだけのステップです. !image.png 7ステップ目の答えは「ずれない」でした.ツアーは差分の外の da3.py と gs.py を読み,gs_videoが「並進÷scale_factor」でガウシアンの空間に戻していることを示します.分子と分母を同じく1000倍するので,関係は崩れません.これを数値の表で確かめていました. !image.png 問い2:APIの既定のモデルでmmを指定すると(10・11ステップ目) 10ステップ目の問いは,APIの既定のモデル(da3-large)で distance_unit="mm" を指定したとき,深度はミリメートルになるか,です. !image.png 11ステップ目の答えは「ミリメートルではない」でした.ツアーは差分の外のREADMEとモデルのコードを読み,メートルを返すのはnestedのモデルだけだと示します.変換の関数は,この区別を見ずに1000倍します.その結果,da3-large では単位のない相対値の1000倍が,警告もなく「mm」として書き出されます.ツアーはこれを必須の指摘にしていました. !image.png この指摘は,私がコードで確かめた範囲では正しいものでした.CLIの既定のモデルはnestedなので問題は出ませんが,APIの既定は da3-large です. ほかに,npzに単位の記録がないことも提案として挙げていました.16ステップ目では,指摘を重さ(必須・良い点・提案・問い)ごとに表にまとめています. !image.png 4.3 CodeVisualizer !image.png !image.png - src/depth_anything_3 全体の依存関係のグラフは,ファイル同士のimportの関係を描いたものです.今回の変更はimportをほとんど変えていないため,グラフは変更前とほぼ同じでした.差分を表示する機能もないので,拡大してファイル名を読めたとしても,どこが変わったのかは分かりません.全体を1枚に収めると,ノードが小さすぎてファイル名も読めませんでした. - 変換の関数 _convert_distance_unit のフローチャートには,4つの分岐が正確に表れました(Code Metricsは行数18,分岐4など).ノードをクリックすると,該当する行に移動できます. - 一方で,「どの値が距離か」「なぜ scale_factor があるときはガウシアンを変えないのか」は,図からは分かりません.2つの仕様の抜けも,図からは読み取れませんでした. 5. 比較と使い分け 比較 | 観点 | CodeRabbit | diff-tour | CodeVisualizer | |---|:---:|:---:|:---:| | 全体像のつかみやすさ | ◎ | ◎ | △ | | 変更範囲の分かりやすさ | ○ | ◎ | × | | コードへの辿りやすさ | ○ | ◎ | ◎ | | 説明の信頼性 | ○ | ◎ | ◎(事実)/×(意味) | | 手軽さ・費用 | ○(数分,試用期間) | ○(作成に約9分,Proの枠でAPI換算約2.21ドル) | ◎(数秒,無料) | | 仕様の抜け(相対深度のモデル) | 気づかず | 「必須」として指摘 | 気づけない | | 仕様の抜け(npzの単位) | 気づかず | 「提案」として指摘 | 気づけない | AIは,指示どおりに動くコードを書きました.しかし,仕様の抜けが2つありました.CodeRabbitは指摘0件,リスク最小と判定し,diff-tourはその2つを見つけました.CodeVisualizerの図は正確でしたが,意味の判断はできませんでした. 使い分けのおすすめ 1章の「人間がレビューで確かめたいこと」に合わせると,次の順で使うのがよいと考えています. 1. 全体像は,PR差分型でつかむ:CodeRabbitなら,PRを開くだけで要約が付きます.どのファイルが何の役割かを最初に把握できます.ただし,非公開リポジトリで使い続けるには有料プランが必要です.月払いだと,最も安いEssentialsでも月額30ドル/ユーザー,今回試したAdvancedは月額90ドル/ユーザーで,学生が個人で払うには高いと感じます.公開リポジトリなら無料です. 2. 重要な変更は,ツアー型で前提から確かめる:diff-tourは時間と利用枠を使いますが,差分の外まで読み,前提の誤りを問いの形で示してくれます. 3. 気になる関数は,静的解析型で構造を確認する:CodeVisualizerなら,分岐の構造を正確に,すぐに見られます.意味の判断は人間が行います. 初めて触るリポジトリなら,その前にリポジトリ全体型で構造をつかんでおくと,変更の前提を考えやすくなるはずです(今回は試していません). まとめ - 課題:AIの差分は,量が多く,重要な箇所が埋もれ,前提が残らず,説明は自信を持って書かれています.人間が全部の行を読まずに確かめる手段が必要です. - なぜツールか:チャットに貼るだけでは,読ませ方も根拠の見せ方も聞き方しだいです.ツールは,読む手順と根拠の見せ方が決まっています. - ツールの選び方:全体像はPR差分型,前提の確認はツアー型,関数の構造は静的解析型が向いています. - 試した結果:今回のPRでは,2つの仕様の抜けに気づいたのはツアー型のdiff-tourだけでした.書き手は実装と同じClaude Codeで,差を生んだのは読ませ方だと考えています. AIが書いた差分のレビューに悩んでいる方の参考になれば幸いです. 参考文献 紹介のみのツールの出典は,3章のリンク先です(2026年10月閲覧). 1. Sonar, "Sonar Data Reveals Critical Verification Gap in AI Coding," 2026. sonarsource.com 2. CodeRabbit, "Pricing." coderabbit.ai (2026年10月閲覧) 3. init-co-jp, "diff-tour," GitHub. init-co-jp/diff-tour (2026年10月閲覧) 4. DucPhamNgoc08, "CodeVisualizer," GitHub. DucPhamNgoc08/CodeVisualizer (2026年10月閲覧)
Creating Depth GeoTIFFs from Satellite and Aerial Imagery with Depth Anything 3: From Environment Setup to Monocular Depth Inference Introduction This article uses Depth Anything 3 (DA3) to perform monocular depth estimation on a single RGB GeoTIFF. No additional training is performed; we use a publicly available pretrained model. Depth estimated from a single satellite image can be used to generate building models with geometry comparable to LOD2. For an overview of monocular depth estimation and our work on generating 3D building data from satellite imagery, see this explanatory page. As the first step in that workflow, this article covers environment setup on Ubuntu with an NVIDIA GPU, model downloading, depth inference, GeoTIFF export, and inspection in QGIS. Building polygons, a DEM, and camera exterior orientation data are not used as inputs. For the satellite imagery example, we used an archived WorldView-2 image with a resolution of 50 cm. Because lossy compression noise made building boundaries unclear in the original image, we first applied 2× super-resolution and image enhancement. The resulting GeoTIFF is 2048×1955 pixels. When we performed monocular depth estimation on the entire image, the predicted boundaries appeared blurred, possibly because the whole image was downscaled internally. We therefore tried splitting the image into 300-pixel tiles, running inference on each tile, and blending the overlapping predictions. This approach was inspired by our experience with Depth Anything 2 a little over a year earlier, when depth estimation for the same area produced a map that appeared tilted across the entire scene. For our image, tiling produced visually sharper building boundaries, as it had with Depth Anything 2. However, this is a visual comparison, not a validation of building heights or roof slopes against measurements. The output is relative depth. It does not directly provide building heights in meters, a DSM, or a DEM. Generating 3D building models and placing them on a DEM are separate postprocessing steps. 1. Model and Processing Pipeline We use depth-anything/DA3MONO-LARGE. Its official model card describes it as a model for monocular relative depth estimation. It is a different model from DA3-LARGE and DA3METRIC-LARGE. - Depth Anything 3: official repository - DA3MONO-LARGE: model card and weights The workflow is: read the RGB GeoTIFF → run DA3 inference → resize the depth prediction to the original pixel grid → save as GeoTIFF. The tiled version splits the image before inference and blends the depth tiles before saving. We use the official API for DA3 itself. GeoTIFF input/output and tile blending are implemented in the additional scripts provided in this article. This is not an official DA3 geospatial tiled inference feature. 2. Environment The environment used in this article is listed below. All commands are intended for Bash on Ubuntu. | Item | Configuration | |---|---| | OS | Ubuntu 24.04 | | GPU | NVIDIA RTX PRO 6000 Blackwell Max-Q | | Python | 3.10 / dedicated conda environment | | PyTorch | 2.9.1, CUDA 12.8 wheel | | torchvision | 0.24.1 | | xFormers | 0.0.33.post2 | | NumPy | 1.26.4 | | OpenCV | opencv-python 4.10.0.84 | | MoviePy | 1.0.3 | | Model | DA3MONO-LARGE | | GeoTIFF input/output | Rasterio | The memory capacity of this GPU is not a requirement for using the workflow. GPU memory requirements depend on input resolution and the environment. This article does not evaluate minimum memory requirements or processing speed on other GPUs. For the PyTorch version combination, see the official previous versions page. For xFormers compatibility, see the 0.0.33.post2 release notes. This procedure pins a combination of major packages rather than upgrading everything to the latest version. The “CUDA Version” shown by nvidia-smi, the Toolkit version shown by nvcc --version, and the CUDA version used by PyTorch, shown by torch.version.cuda, have different meanings. This procedure uses the CUDA 12.8 PyTorch wheel and does not replace the system CUDA Toolkit. Install a suitable NVIDIA GPU driver beforehand. 3. Create the Working Directory and Conda Environment We assume that Miniconda or Anaconda is already available. conda create -n da3_bw python=3.10 pip -y conda activate da3_bw mkdir -p ~/da3_geotiff/input ~/da3_geotiff/output ~/da3_geotiff/logs cd ~/da3_geotiff nvidia-smi git --version tmux -V If git or tmux is unavailable, install it through Ubuntu's package manager. sudo apt-get update sudo apt-get install -y git tmux Save all files listed in “Appendix: Scripts” at the end of this article directly under ~/da3_geotiff/. Copy the code from each expandable section and save it using the filename in its heading. No separate custom ZIP download or private repository is required. | File | Purpose | |---|---| | constraints.txt | Pin major package versions | | setup.sh | Install official code and dependencies | | check_env.py | Check CUDA and imports | | download_model.py | Download official model weights | | infer_geotiff.py | Run inference on the whole image | | da3_tile_io.py | Handle GeoTIFF input/output for tiled inference | | infer_geotiff_tiled.py | Run tiled inference and blend predictions | 4. Install DA3 and Its Dependencies conda activate da3_bw cd ~/da3_geotiff bash setup.sh setup.sh performs the following steps: 1. Install the CUDA 12.8 versions of PyTorch, torchvision, and xFormers. 2. Clone the official repository into vendor/Depth-Anything-3. 3. Install the basic DA3 dependencies, Rasterio, and other required packages. 4. Run pip check, check imports, and test matrix multiplication on CUDA. 5. Save the Git commit ID and pip freeze output in logs/. The official code is obtained with the following command: # This is executed by setup.sh; you do not need to run it again manually. git clone https://github.com/ByteDance-Seed/Depth-Anything-3.git vendor/Depth-Anything-3 Only the basic dependencies are installed; optional components for Gradio and 3D Gaussian output are not installed. Depth inference uses infer_gs=False. To check the environment again, run: python -m pip check python check_env.py Check that the output includes CUDA runtime: 12.8, the GPU name, and Imports and CUDA matrix multiplication: OK. These checks alone do not establish that inference with the model weights succeeds. Although major packages are pinned, this is not a fully locked environment with every dependency and repository commit fixed in advance. If future changes cause dependency conflicts, inspect the error log and downloaded commit before removing the constraints and upgrading packages. 5. Download the DA3MONO-LARGE Weights python download_model.py The model is downloaded from the official Hugging Face repository, depth-anything/DA3MONO-LARGE, into models/DA3MONO-LARGE/. The script resolves a revision, downloads that specific revision, and records its ID in download_revision.txt. cat models/DA3MONO-LARGE/download_revision.txt cat logs/da3_git_commit.txt These files record the versions retrieved at download time. They do not guarantee that a new download on another day will resolve to the same revision. Retain the model directory and environment records for reproducibility. Inference loads the locally saved weights. The scripts do not upload input imagery to Hugging Face. 6. Prepare the Input GeoTIFF The examples use input/area_rgb.tif. Place your own image at that path, or change the input path in the commands below. The input requirements are: - An RGB source image with an 8-bit uint8 pixel type - A GeoTIFF with a CRS and affine transform - Bands 1, 2, and 3 used as R, G, and B by default - No previously estimated depth or height image as input For 16-bit satellite imagery, first convert it to 8-bit RGB using a consistent method across the entire image. Avoid a simple astype(uint8) conversion or independent contrast adjustments for each tile. The script stops on 16-bit imagery rather than converting it automatically. Example input inspection: python - <<'PY' import rasterio with rasterio.open('input/area_rgb.tif') as src: print('size:', src.width, src.height) print('bands:', src.count) print('dtype:', src.dtypes) print('CRS:', src.crs) print('transform:', src.transform) print('color interpretation:', src.colorinterp) PY If the RGB band order differs, add --bands R_number G_number B_number when running inference. For example, use --bands 3 2 1 if R, G, and B correspond to bands 3, 2, and 1. An additional band in a four-band image is not passed to the model. However, alpha and NoData masks recognized by Rasterio are used to identify valid pixels. Missing pixels are filled with the RGB median during inference and restored to NoData in the output. This does not eliminate their influence on predictions near missing regions. 7. Start with Whole-Image Inference Use tmux for long-running processing. tmux new -s da3 Run the following commands inside the session: conda activate da3_bw cd ~/da3_geotiff mkdir -p output logs set -o pipefail python -u infer_geotiff.py \ --input input/area_rgb.tif \ --output-prefix output/area_da3_full_r1008 \ --process-res 1008 \ 2>&1 | tee logs/area_da3_full_r1008.log --process-res 1008 specifies the approximate longest side of the image passed to the model. It does not split the original image into 1008-pixel tiles. Preprocessing resizes the image without cropping and adjusts the resolution to the model's patch size. The output depth is resized to the original image dimensions using bilinear interpolation before saving. Therefore, matching the input and output pixel dimensions does not mean that details lost during downscaled inference have been recovered. Press Ctrl+B, then D, to detach from tmux. Reattach with: tmux attach -t da3 8. Use 300-Pixel Tiles to Inspect Building Details Instead of downscaling the whole image, run inference on small regions of the original image. Here we use 300-pixel crops, 100-pixel overlaps, and a model input size of 518 pixels. python -u infer_geotiff_tiled.py \ --input input/area_rgb.tif \ --output-prefix output/area_da3_t300_o100_r518_shift \ --tile-size 300 \ --overlap 100 \ --process-res 518 \ --alignment shift \ 2>&1 | tee logs/area_da3_t300_o100_r518_shift.log | Argument | Meaning | |---|---| | --tile-size 300 | Crop size in the original image, normally 300×300 pixels | | --overlap 100 | Overlap between adjacent tiles; the normal stride is 200 pixels | | --process-res 518 | Input size used when passing a 300-pixel RGB tile to the model | | --alignment shift | Correct only the offset using depth differences in overlapping regions | | --output-prefix | Common filename prefix for the output files | 518 is a multiple of 14. This wrapper requires process-res to be a multiple of 14. Upscaling a 300-pixel tile to 518 pixels is a model input preparation step; it does not improve the actual ground resolution of the imagery. For example, a 300-pixel tile covers 75×75 m in an image with a resolution of 0.25 m/pixel. At 0.5 m/pixel, it covers 150×150 m. The proportion occupied by buildings and the surrounding context therefore differ even with the same 300-pixel tile size. Equivalent accuracy is not guaranteed for other regions or image resolutions. At image boundaries, crop positions are shifted back to keep the tiles inside the image, so the final overlap may exceed 100 pixels. If the image width or height is less than 300 pixels, the image is padded using edge pixel values. A 2048×1955 image produces 10×10 tiles, or 100 tiles in total. The model is loaded once at the start, and tiles are processed sequentially. Each prediction is resized back to the original 300-pixel tile grid and blended into the mosaic. Blending Modes: shift, affine, and none | Mode | Correction | Characteristics | |---|---|---| | shift | Offset only | Does not rescale depth differences within each tile | | affine | Scale and offset | Aligns scales in overlapping regions, but also changes the magnitude of local relief | | none | No value correction | Takes a weighted average of the original predictions; differences between tiles may remain | In this workflow, we explicitly use shift to avoid the effects of scale correction on local height differences during comparison. The supplied code defaults to affine, so do not omit this argument. For shift alignment, let {% katex inline %}d{% endkatex %} be the current tile's depth and {% katex inline %}m{% endkatex %} the depth in the overlapping region of the existing mosaic. For valid overlapping pixels, apply: {% katex %} b = \operatorname{median}(m-d),\qquad d' = d+b {% endkatex %} For the first tile, or a tile without valid overlap, the offset is set to zero. The corrected tiles are then blended with a weighted average that assigns greater weights toward each tile's center. {% katex %} D(x,y)=\frac{\sum_i w_i(x,y)d_i'(x,y)}{\sum_i w_i(x,y)} {% endkatex %} “Alignment” here does not geometrically transform the image. It aligns the reference levels of depth values predicted at the same pixel locations. The tiles are not independently normalized to 0–1 using their own minimum and maximum values before blending. Nevertheless, shift alignment cannot resolve true scale differences. Scan-order-dependent error accumulation and blurring caused by averaging overlapping regions may remain. 9. Run Inference on Another Area from Scratch Change the input path and output name, then run the same command. Depth results or tiles from the previous area are not reused. python -u infer_geotiff_tiled.py \ --input input/another_area_rgb.tif \ --output-prefix output/another_area_da3_t300_o100_r518_shift \ --tile-size 300 --overlap 100 --process-res 518 \ --alignment shift \ 2>&1 | tee logs/another_area_da3_t300_o100_r518_shift.log The scripts prevent overwriting existing outputs. When rerunning inference on the same image, specify an unused --output-prefix. The DA3 scripts in this article do not support resuming an interrupted run. 10. Understanding the Output Files | Filename suffix | Contents | Main use | |---|---|---| | _depth.tif | Float32 relative depth; larger values indicate greater distance | Numerical analysis and downstream processing | | _near01.tif | Display GeoTIFF normalized to 0–1, with nearer regions set toward 1 | Visualization in QGIS | | _near.png | 8-bit PNG using the same display representation | Image comparisons and article illustrations | | _metadata.json | Inference settings, GPU, image dimensions, tile corrections, and other information | Checking execution conditions | The GeoTIFF outputs retain the input CRS, affine transform, width, and height. The output NoData value is -9999. Invalid pixel locations are preserved, but arbitrary TIFF tags and the original NoData value are not all copied unchanged. For tiled inference, _depth.tif contains relative depth after blending corrections, not the uncorrected predictions for individual tiles. Shift or affine corrections can produce negative values. The script does not save unblended tile predictions as separate files. The near01 Display Is Not Building Height Let {% katex inline %}P_1,P_{99}{% endkatex %} be the 1st and 99th percentiles of the valid depth values across the blended mosaic. The display image is computed as: {% katex %} N=\operatorname{clip}\left(\frac{P_{99}-D}{P_{99}-P_1},0,1\right) {% endkatex %} Nearer regions appear white and farther regions black. This is contrast adjustment and a linear reversal of the depth direction, not inverse depth {% katex inline %}1/D{% endkatex %} or a conversion to height in meters. Because the display range is determined independently for each image, the same brightness in near01 outputs from different regions does not represent the same depth or height. Use _depth.tif for numerical processing. 11. Inspect the Results in QGIS 1. Load the original RGB GeoTIFF and _near01.tif into QGIS. 2. Display _near01.tif using “Singleband gray.” 3. Set the minimum to 0, maximum to 1, and color ramp from black to white. 4. At the same location and scale, inspect building boundaries, roof ridges, roof surfaces, and their relationship to roads. When displaying _depth.tif, stretch it according to its actual value range. A fixed 0–1 range may cause many pixels to appear solid white or black. A white image alone does not establish that inference failed. Example checks for coordinates and output values: python - <<'PY' import numpy as np import rasterio source='input/area_rgb.tif' result='output/area_da3_t300_o100_r518_shift_depth.tif' with rasterio.open(source) as src, rasterio.open(result) as dst: assert src.crs == dst.crs assert src.transform == dst.transform assert (src.width, src.height) == (dst.width, dst.height) source_valid=np.all(src.read_masks([1,2,3]) > 0, axis=0) depth=dst.read(1, masked=True) assert np.array_equal(source_valid, ~np.ma.getmaskarray(depth)) values=depth.compressed() assert values.size and np.isfinite(values).all() print('CRS, pixel grid, and valid pixel locations: OK') print('NoData:', dst.nodata) print('min / median / max:', values.min(), np.median(values), values.max()) PY If you change the RGB bands, update [1,2,3] in the checking code accordingly. These checks validate data positioning and format; they do not evaluate depth accuracy. Example Inference Results The image below shows the input and the monocular depth estimation result from Depth Anything 3. The input is an archived WorldView-2 image processed with 2× super-resolution. Input image and Depth Anything 3 monocular depth estimation result: !Input imagery and the relative depth predicted by Depth Anything 3 Left: input image after 2× super-resolution. Right: estimated relative depth displayed with nearer regions brighter. Differences between buildings and the surrounding ground, as well as relief on some roof surfaces, appear as variations in brightness. However, broad brightness variations are also visible across the image. These may include terrain effects and errors from estimation or tile blending; this image alone cannot distinguish their causes. Roof brightness should therefore not be interpreted directly as building height. Relative differences from the surrounding ground and agreement with measured values must be checked separately. 12. Common Problems | Symptom | Checks and actions | |---|---| | CUDA GPU required / CUDA is not detected | Check nvidia-smi, python check_env.py, and the active conda environment | | CUDA out of memory | Lower process-res for whole-image inference or use tiled inference. For tiled inference, compare 518 with another multiple of 14, such as 392. | | xFormers version mismatch | Check the PyTorch/xFormers combination. Do not upgrade only one of them in the environment. | | Model directory missing | Complete python download_model.py. If the model is stored elsewhere, specify --model. | | Input must be RGB uint8 | Check that the input is the source RGB image and that band selection and data type are correct | | Output appears entirely white or black | Check the display range. Start by displaying near01 over 0–1 and inspecting depth minimum and maximum values. | | Depth discontinuities at tile boundaries | Compare overlap widths and blending modes. Distinguish actual height differences from blending errors. | | Buildings appear too high or too low overall | Check whether relative depth is being interpreted as height in meters | | Output exists | Run from scratch with an unused output prefix | This implementation tiles model inputs, but it loads the original RGB image and mosaic arrays into CPU memory. It is not a streaming implementation that processes arbitrarily large GeoTIFFs with constant memory usage. Crop an AOI when working with large-area imagery. 13. Separate the Evaluation Questions Visually sharp building boundaries, correct local depth geometry, and building heights that agree with measurements are separate evaluation questions. For comparisons, use the same source image, crop extent, and display scale when examining whole-image versus tiled inference and different blending modes. Comparisons with DA2 or other models also require attention to internal input resolution and the representation of output values. GeoTIFF export in this workflow preserves image georeferencing. It does not calibrate depth to ground-relative height or elevation. To proceed to a DSM or 3D building models, depth-to-height relationships, ground treatment, and validation against measured values must be addressed separately. References - Depth Anything 3 official repository - DA3MONO-LARGE model card - PyTorch: Previous Versions - xFormers 0.0.33.post2: wheels for PyTorch 2.9.1 Appendix: Scripts The following scripts are the GeoTIFF wrappers used in this article. Save each code block directly in the working directory using the indicated filename. The official model implementation and weights are not included here; obtain them from the official sources using the steps above. constraints.txt <details> <summary>Full contents of constraints.txt</summary> torch==2.9.1 torchvision==0.24.1 xformers==0.0.33.post2 numpy==1.26.4 opencv-python==4.10.0.84 moviepy==1.0.3 </details> setup.sh <details> <summary>Full contents of setup.sh</summary> #!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" if [[ "${CONDA_DEFAULT_ENV:-}" != "da3_bw" ]]; then echo 'Activate the dedicated environment first: conda activate da3_bw' >&2 exit 1 fi mkdir -p vendor models output logs python -m pip install --upgrade pip python -m pip install -c constraints.txt torch torchvision xformers \ --index-url https://download.pytorch.org/whl/cu128 if [[ ! -d vendor/Depth-Anything-3 ]]; then git clone https://github.com/ByteDance-Seed/Depth-Anything-3.git vendor/Depth-Anything-3 fi python -m pip install -c constraints.txt -e ./vendor/Depth-Anything-3 python -m pip install -c constraints.txt rasterio addict python -m pip check git -C vendor/Depth-Anything-3 rev-parse HEAD > logs/da3_git_commit.txt python -m pip freeze > logs/environment_freeze.txt python check_env.py </details> check_env.py <details> <summary>Full contents of check_env.py</summary> import torch import torchvision import xformers import rasterio from depth_anything_3.api import DepthAnything3 print('torch:', torch.__version__) print('torchvision:', torchvision.__version__) print('xformers:', xformers.__version__) print('CUDA runtime:', torch.version.cuda) print('rasterio:', rasterio.__version__) assert torch.cuda.is_available(), 'CUDA GPU is unavailable' print('GPU:', torch.cuda.get_device_name(0)) print('Capability:', torch.cuda.get_device_capability(0)) x = torch.randn(512, 512, device='cuda') y = x @ x torch.cuda.synchronize() assert torch.isfinite(y).all() print('Imports and CUDA matrix multiplication: OK (model inference not yet tested)') </details> download_model.py <details> <summary>Full contents of download_model.py</summary> from pathlib import Path from huggingface_hub import HfApi, snapshot_download root = Path(__file__).resolve().parent repo = 'depth-anything/DA3MONO-LARGE' info = HfApi().model_info(repo) path = snapshot_download( repo_id=repo, revision=info.sha, local_dir=str(root / 'models' / 'DA3MONO-LARGE'), allow_patterns=['*.json', '*.safetensors', '*.md', 'LICENSE*'], ) (root / 'models' / 'DA3MONO-LARGE' / 'download_revision.txt').write_text( f'{repo}\n{info.sha}\n', encoding='utf-8') print('Model downloaded:', path) </details> infer_geotiff.py <details> <summary>Full contents of infer_geotiff.py</summary> """Single-image DA3MONO inference. Relative depth, never metric height. Whole-image inference avoids independent tile scales. The API resizes, without cropping; the depth is bilinearly restored to the source pixel grid. Original CRS/affine and the intersection of RGB valid masks are retained. Unknown extra bands are ignored; rasterio-recognized alpha masks are honored. """ from pathlib import Path import argparse import json import time import numpy as np import rasterio from PIL import Image NODATA = -9999.0 def read_rgb(path, bands): with rasterio.open(path) as src: if min(bands) < 1 or max(bands) > src.count: raise ValueError(f'Invalid RGB bands {bands}; source has {src.count} bands') rgb = src.read(bands) if rgb.dtype != np.uint8: raise ValueError('Input must be RGB uint8. Convert 16-bit/float data explicitly first.') if src.crs is None: raise ValueError('Input has no CRS; supply a georeferenced RGB GeoTIFF') valid = np.all(src.read_masks(bands) > 0, axis=0) if not valid.any(): raise ValueError('Input has no valid RGB pixels') profile = dict(driver='GTiff', width=src.width, height=src.height, count=1, dtype='float32', crs=src.crs, transform=src.transform, nodata=NODATA, compress='deflate', predictor=3, tiled=True, blockxsize=256, blockysize=256, BIGTIFF='IF_SAFER') rgb = np.moveaxis(rgb, 0, -1).copy() # Fill masked pixels only for inference; their outputs remain NoData. if not valid.all(): rgb[~valid] = np.median(rgb[valid], axis=0).astype(np.uint8) print('Warning: masked areas can affect nearby predictions.', flush=True) return rgb, valid, profile def restore_depth(depth, height, width): depth = np.asarray(depth, dtype=np.float32) if depth.ndim != 2 or not np.isfinite(depth).all(): raise ValueError('Model depth must be a finite 2-D array') if depth.shape != (height, width): depth = np.asarray(Image.fromarray(depth).resize( (width, height), resample=Image.Resampling.BILINEAR), dtype=np.float32) return depth.copy() def write_raster(path, data, valid, profile, description, tags): arr = np.where(valid, data, NODATA).astype(np.float32) with rasterio.open(path, 'w', **profile) as dst: dst.write(arr, 1) dst.set_band_description(1, description) dst.update_tags(**tags) v = arr[valid].astype(np.float64) dst.update_tags(1, STATISTICS_MINIMUM=str(v.min()), STATISTICS_MAXIMUM=str(v.max()), STATISTICS_MEAN=str(v.mean()), STATISTICS_STDDEV=str(v.std()), STATISTICS_VALID_PERCENT=str(100 * valid.mean())) def save_results(prefix, depth, valid, profile, meta): vals = depth[valid] if not np.isfinite(vals).all(): raise ValueError('Non-finite predictions found') lo, hi = np.percentile(vals, [1, 99]) if hi <= lo: raise ValueError('Depth map is effectively constant; do not treat it as a valid result') # Display only: linear sign reversal, NOT inverse-depth or metric height. near = np.clip((hi - depth) / (hi - lo), 0, 1).astype(np.float32) tags = dict(model='DA3MONO-LARGE', units='relative_arbitrary_units', inference='whole_image', process_res=str(meta['process_res']), native_prediction_shape=str(meta['native_prediction_shape'])) write_raster(str(prefix) + '_depth.tif', depth, valid, profile, 'Relative depth (larger = farther); NOT height in meters', tags) write_raster(str(prefix) + '_near01.tif', near, valid, profile, 'Display only: near=1 far=0; clipped P1-P99; NOT height', dict(tags, units='normalized_display_only', display_formula='clip((P99-depth)/(P99-P1),0,1)', display_p1=str(lo), display_p99=str(hi))) preview = np.zeros(depth.shape, dtype=np.uint8) preview[valid] = np.rint(near[valid] * 255).astype(np.uint8) Image.fromarray(preview).save(str(prefix) + '_near.png') meta.update(depth_statistics={ 'min': float(vals.min()), 'max': float(vals.max()), 'p1': float(lo), 'median': float(np.median(vals)), 'p99': float(hi)}, valid_pixels=int(valid.sum()), nodata=NODATA, depth_units='arbitrary; not meters', preview='near=white; P1/P99 clipping; invalid=black', output_resampling='bilinear onto original pixel grid; no new detail added') Path(str(prefix) + '_metadata.json').write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8') print(json.dumps(meta['depth_statistics'], indent=2), flush=True) def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--input', required=True) p.add_argument('--output-prefix', required=True) p.add_argument('--model', default=str(Path(__file__).resolve().parent / 'models/DA3MONO-LARGE')) p.add_argument('--process-res', type=int, default=1008, help='Inference longest side, divisible by 14; try 1008 then 2016') p.add_argument('--bands', nargs=3, type=int, default=[1, 2, 3], metavar=('R', 'G', 'B')) p.add_argument('--device', default='cuda:0') args = p.parse_args() if args.process_res < 28 or args.process_res % 14: p.error('--process-res must be >=28 and divisible by 14') inp = Path(args.input).expanduser().resolve() prefix = Path(args.output_prefix).expanduser().resolve() if not Path(args.model).is_dir(): p.error('Local model directory missing; run download_model.py first') for suffix in ['_depth.tif', '_near01.tif', '_near.png', '_metadata.json']: if Path(str(prefix) + suffix).exists(): p.error(f'Output exists: {prefix}{suffix}; use a new output prefix') prefix.parent.mkdir(parents=True, exist_ok=True) rgb, valid, profile = read_rgb(inp, args.bands) height, width = valid.shape if min(height, width) / max(height, width) * args.process_res < 28: p.error('Image aspect ratio is too extreme at this process resolution') print(f'Input: {width}x{height}, RGB bands {args.bands}; process-res={args.process_res}', flush=True) import torch from depth_anything_3.api import DepthAnything3 if not args.device.startswith('cuda') or not torch.cuda.is_available(): p.error('This workflow requires a CUDA GPU') torch.cuda.set_device(torch.device(args.device)) print(f'GPU: {torch.cuda.get_device_name()}', flush=True) model = DepthAnything3.from_pretrained(args.model).to(args.device).eval() if model.model_name != 'da3mono-large': raise ValueError(f'This workflow expects da3mono-large, got {model.model_name}') start = time.time() with torch.inference_mode(): prediction = model.inference( [rgb], process_res=args.process_res, process_res_method='upper_bound_resize', infer_gs=False, export_dir=None, ) native = np.asarray(prediction.depth[0], dtype=np.float32) print('Native depth shape:', native.shape, flush=True) depth = restore_depth(native, height, width) meta = dict(input=str(inp), model=str(Path(args.model).resolve()), model_name=model.model_name, process_res=args.process_res, native_prediction_shape=list(native.shape), output_shape=[height, width], crs=str(profile['crs']), transform=list(profile['transform']), rgb_bands=args.bands, torch_version=torch.__version__, cuda_runtime=torch.version.cuda, gpu=torch.cuda.get_device_name(), inference_seconds=time.time() - start) save_results(prefix, depth, valid, profile, meta) print('Done:', prefix, flush=True) if __name__ == '__main__': main() </details> da3_tile_io.py <details> <summary>Full contents of da3_tile_io.py</summary> """Raster IO for experimental tiled relative depth mosaics.""" from pathlib import Path import argparse import json import time import numpy as np import rasterio from PIL import Image NODATA = -9999.0 def read_rgb(path, bands): with rasterio.open(path) as src: if min(bands) < 1 or max(bands) > src.count: raise ValueError(f'Invalid RGB bands {bands}; source has {src.count} bands') rgb = src.read(bands) if rgb.dtype != np.uint8: raise ValueError('Input must be RGB uint8. Convert 16-bit/float data explicitly first.') if src.crs is None: raise ValueError('Input has no CRS; supply a georeferenced RGB GeoTIFF') valid = np.all(src.read_masks(bands) > 0, axis=0) if not valid.any(): raise ValueError('Input has no valid RGB pixels') profile = dict(driver='GTiff', width=src.width, height=src.height, count=1, dtype='float32', crs=src.crs, transform=src.transform, nodata=NODATA, compress='deflate', predictor=3, tiled=True, blockxsize=256, blockysize=256, BIGTIFF='IF_SAFER') rgb = np.moveaxis(rgb, 0, -1).copy() # Fill masked pixels only for inference; their outputs remain NoData. if not valid.all(): rgb[~valid] = np.median(rgb[valid], axis=0).astype(np.uint8) print('Warning: masked areas can affect nearby predictions.', flush=True) return rgb, valid, profile def restore_depth(depth, height, width): depth = np.asarray(depth, dtype=np.float32) if depth.ndim != 2 or not np.isfinite(depth).all(): raise ValueError('Model depth must be a finite 2-D array') if depth.shape != (height, width): depth = np.asarray(Image.fromarray(depth).resize( (width, height), resample=Image.Resampling.BILINEAR), dtype=np.float32) return depth.copy() def write_raster(path, data, valid, profile, description, tags): arr = np.where(valid, data, NODATA).astype(np.float32) with rasterio.open(path, 'w', **profile) as dst: dst.write(arr, 1) dst.set_band_description(1, description) dst.update_tags(**tags) v = arr[valid].astype(np.float64) dst.update_tags(1, STATISTICS_MINIMUM=str(v.min()), STATISTICS_MAXIMUM=str(v.max()), STATISTICS_MEAN=str(v.mean()), STATISTICS_STDDEV=str(v.std()), STATISTICS_VALID_PERCENT=str(100 * valid.mean())) def save_results(prefix, depth, valid, profile, meta): vals = depth[valid] if not np.isfinite(vals).all(): raise ValueError('Non-finite predictions found') lo, hi = np.percentile(vals, [1, 99]) if hi <= lo: raise ValueError('Depth map is effectively constant; do not treat it as a valid result') # Display only: linear sign reversal, NOT inverse-depth or metric height. near = np.clip((hi - depth) / (hi - lo), 0, 1).astype(np.float32) tags = dict(model='DA3MONO-LARGE', units='relative_arbitrary_units', inference=meta['inference'], alignment=meta['alignment'], tile_size=str(meta['tile_size']), overlap=str(meta['overlap']), process_res=str(meta['process_res']), native_prediction_shape=str(meta['native_prediction_shape'])) write_raster(str(prefix) + '_depth.tif', depth, valid, profile, 'Relative depth (larger = farther); NOT height in meters', tags) write_raster(str(prefix) + '_near01.tif', near, valid, profile, 'Display only: near=1 far=0; clipped P1-P99; NOT height', dict(tags, units='normalized_display_only', display_formula='clip((P99-depth)/(P99-P1),0,1)', display_p1=str(lo), display_p99=str(hi))) preview = np.zeros(depth.shape, dtype=np.uint8) preview[valid] = np.rint(near[valid] * 255).astype(np.uint8) Image.fromarray(preview).save(str(prefix) + '_near.png') meta.update(depth_statistics={ 'min': float(vals.min()), 'max': float(vals.max()), 'p1': float(lo), 'median': float(np.median(vals)), 'p99': float(hi)}, valid_pixels=int(valid.sum()), nodata=NODATA, depth_units='arbitrary; not meters', preview='near=white; P1/P99 clipping; invalid=black', output_resampling='bilinear onto original pixel grid; no new detail added') Path(str(prefix) + '_metadata.json').write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8') print(json.dumps(meta['depth_statistics'], indent=2), flush=True) </details> infer_geotiff_tiled.py <details> <summary>Full contents of infer_geotiff_tiled.py</summary> """DA3MONO independent overlapping tile inference; experimental relative-depth mosaic.""" import argparse from pathlib import Path import time import numpy as np from da3_tile_io import read_rgb, restore_depth, save_results def starts(length, tile, overlap): if length <= tile: return [0] pos = list(range(0, length - tile + 1, tile - overlap)) if pos[-1] != length - tile: pos.append(length - tile) return pos def fit_overlap(x, y, mode): """Robust positive affine fit; reject poorly constrained scale estimates.""" x, y = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64) if not len(x): return 1., 0., 'unanchored' step = max(1, len(x) // 20000) x, y = x[::step], y[::step] shift = float(np.median(y - x)) if mode == 'shift' or len(x) < 128: return 1., shift, 'shift' keep = np.ones(len(x), dtype=bool) a, b = 1., shift for _ in range(4): xx, yy = x[keep], y[keep] if len(xx) < 128 or np.std(xx) < 1e-6 * max(1., abs(np.mean(xx))): return 1., shift, 'fallback_flat_overlap' dx, dy = xx - xx.mean(), yy - yy.mean() corr = np.dot(dx, dy) / max(np.linalg.norm(dx) * np.linalg.norm(dy), 1e-20) a = float(np.dot(dx, dy) / np.dot(dx, dx)) if corr < .5 or not .25 <= a <= 4.: return 1., shift, 'fallback_unstable_scale' b = float(np.median(yy - a * xx)) residual = y - (a * x + b) med = np.median(residual) mad = np.median(np.abs(residual - med)) keep = np.abs(residual - med) <= max(3 * 1.4826 * mad, 1e-6) return a, b, 'affine' def blend_weight(height, width): # Positive edge weights retain image boundary pixels. wy = np.maximum(np.sin(np.pi * (np.arange(height) + .5) / height)**2, .02) wx = np.maximum(np.sin(np.pi * (np.arange(width) + .5) / width)**2, .02) return wy[:, None] * wx[None, :] def mosaic(rgb, valid, predict, tile, overlap, alignment): h, w = valid.shape total = np.zeros((h, w), dtype=np.float64) weights = np.zeros((h, w), dtype=np.float64) records = [] ys, xs = starts(h, tile, overlap), starts(w, tile, overlap) for y in ys: for x in xs: sl = np.s_[y:min(y+tile, h), x:min(x+tile, w)] mask = valid[sl] if not mask.any(): continue patch = rgb[sl] ph, pw = mask.shape # Small images padded; normal edge tiles remain exactly tile x tile. padded = np.pad(patch, ((0, tile-ph), (0, tile-pw), (0, 0)), mode='edge') native = predict(padded) depth = restore_depth(native, tile, tile)[:ph, :pw] old_w, old_sum = weights[sl], total[sl] common = mask & (old_w > 0) a, b, status = 1., 0., 'none' if alignment != 'none': a, b, status = fit_overlap(depth[common], old_sum[common]/old_w[common], alignment) corrected = a * depth + b weight = blend_weight(ph, pw) * mask old_sum += corrected * weight old_w += weight records.append(dict(x=x, y=y, width=pw, height=ph, native_shape=list(np.shape(native)), scale=a, shift=b, alignment_status=status, overlap_pixels=int(common.sum()))) print(f'Tile {len(records)}/{len(ys)*len(xs)} ({x},{y}) {status}: a={a:.4g}, b={b:.4g}', flush=True) if np.any(valid & (weights <= 0)): raise RuntimeError('Valid pixels not covered') output = np.zeros((h, w), dtype=np.float32) output[valid] = (total[valid] / weights[valid]).astype(np.float32) return output, records def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--input', required=True) p.add_argument('--output-prefix', required=True) p.add_argument('--model', default=str(Path(__file__).resolve().parent/'models/DA3MONO-LARGE')) p.add_argument('--tile-size', type=int, default=300) p.add_argument('--overlap', type=int, default=100, help='Overlap pixels, NOT stride') p.add_argument('--process-res', type=int, default=518, help='Model input longest side, multiple of 14') p.add_argument('--alignment', choices=['affine', 'shift', 'none'], default='affine') p.add_argument('--bands', nargs=3, type=int, default=[1,2,3]) p.add_argument('--device', default='cuda:0') args = p.parse_args() if args.tile_size < 28 or not 0 <= args.overlap < args.tile_size: p.error('Require tile-size >=28 and 0 <= overlap < tile-size') if args.alignment != 'none' and args.overlap == 0: p.error('Alignment needs overlap >0; use --alignment none for nonoverlapping tiles') if args.process_res < 28 or args.process_res % 14: p.error('process-res must be >=28 and divisible by 14') if not Path(args.model).is_dir(): p.error('Model directory missing; specify existing --model path') prefix = Path(args.output_prefix).expanduser().resolve() for suffix in ['_depth.tif','_near01.tif','_near.png','_metadata.json']: if Path(str(prefix)+suffix).exists(): p.error(f'Output exists: {prefix}{suffix}; use a new prefix') rgb, valid, profile = read_rgb(args.input, args.bands) import torch from depth_anything_3.api import DepthAnything3 if not args.device.startswith('cuda') or not torch.cuda.is_available(): p.error('CUDA GPU required') torch.cuda.set_device(torch.device(args.device)) model = DepthAnything3.from_pretrained(args.model).to(args.device).eval() if model.model_name != 'da3mono-large': p.error('Expected DA3MONO-LARGE') def predict(patch): with torch.inference_mode(): result = model.inference([patch], process_res=args.process_res, process_res_method='upper_bound_resize', infer_gs=False, export_dir=None) return np.asarray(result.depth[0], dtype=np.float32) start = time.time() depth, records = mosaic(rgb, valid, predict, args.tile_size, args.overlap, args.alignment) meta = dict(input=str(Path(args.input).resolve()), model=str(Path(args.model).resolve()), model_name=model.model_name, process_res=args.process_res, native_prediction_shape=records[0]['native_shape'], output_shape=list(valid.shape), crs=str(profile['crs']), transform=list(profile['transform']), rgb_bands=args.bands, inference='independent_overlapping_tiles', tile_size=args.tile_size, overlap=args.overlap, stride=args.tile_size-args.overlap, alignment=args.alignment, alignment_warning='Experimental relative mosaic; scan-order drift and disconnected scales possible; not metric depth or height', tiles=records, torch_version=torch.__version__, cuda_runtime=torch.version.cuda, gpu=torch.cuda.get_device_name(), inference_seconds=time.time()-start) prefix.parent.mkdir(parents=True, exist_ok=True) save_results(prefix, depth, valid, profile, meta) print('Done:', prefix, flush=True) if __name__ == '__main__': main() </details>
Depth Anything 3 estimates depth and camera poses from multiple photos. I'm imagining a location-scouting tool: explore a rough reconstruction before a shoot, with uncertain areas visibly marked. Even a 3D model should know when to shrug 😄 ByteDance-Seed/Depth-Anything-3