ブラウザでRGB画像から擬似NDVIを生成して可視化してみた
この記事で扱うのは、近赤外を実測して求める真のNDVIではありません。
RGB画像の見た目の特徴から Red* と NIR* の代理値を割り当て、擬似NDVI として可視化する方法を紹介します。

完成イメージは次のようになります。
そのため、ここで得られる値は、マルチスペクトルカメラや衛星データから計算される物理的なNDVIそのものではありません。あくまで、RGB画像から植生らしさを見やすくするための近似的な可視化です。
NDVIとは何か
衛星データを用いた植生監視の初期研究としてよく知られているのが Rouse ら(1974) の研究です。
この研究では、ERTS-1(後の Landsat-1)の MSS データを用いて、広域の植生状態を定量的に評価する考え方が示されました。
続く Tucker(1979) は、赤と写真用近赤外の線形結合が、光合成活性バイオマスの監視に有効であることを示しました。
この研究により、赤と近赤外の組み合わせが植生指標として有効であることが広く認識されるようになりました。
<近年の農業におけるNDVIの利用>
近年の農業では、ドローン(UAV)や衛星、地上センサを利用して、作物の生育状態を広範囲に把握する取り組みが一般的になっています。
こうしたリモートセンシングの分野で、現在も広く使われている代表的な指標が NDVI(Normalized Difference Vegetation Index) です。
NDVIは、赤(Red)と近赤外(NIR) の2つの波長帯から計算される植生指数で、植物の活力度や植生の密度を簡潔に表現するために用いられます。式は次のように定義されます。
NDVI = (NIR - Red) / (NIR + Red)
NDVIの値はおおむね -1 から 1 の範囲を取り、一般的には次のように解釈されます。
- 水面:負の値
- 岩石・砂・裸地:0付近から低い正の値
- 健全で密な植生:高い値
つまり、赤を強く吸収し、近赤外を強く反射する植物ほどNDVIが高くなりやすいということです。
NDVIはどのように得られるのか
NDVIはセンサが直接撮影しているものではなく、赤と近赤外の画像から計算によって求められる指数です。
リモートセンシングのセンサは、波長帯ごとの反射強度を取得します。NDVIは、そのうち赤と近赤外の2つの波長帯を組み合わせて計算されます。
このとき、センサが出力する値は必ずしもそのまま物理量として扱えるとは限りません。センサが出力する画素値は DN(Digital Number) と呼ばれ、これはまだ放射輝度や反射率に変換される前の生の値です。
厳密な比較や時系列解析を行う場合には、DNに対して放射補正や反射率補正を行い、物理量に近い形へ整える処理が必要になります。
なぜ赤と近赤外で植物の状態が分かるのか
NDVIが植物の状態を捉えやすい理由は、葉の光学特性にあります。
健全な植物の葉は、一般に次のような特徴を持ちます。
- 可視光の赤(Red)を強く吸収する
- 近赤外(NIR)を強く反射する
これは主に、クロロフィルによる赤の吸収と、葉内部の細胞構造による近赤外の散乱によって生じます。
このため、赤と近赤外の反射強度を比較すると、植物の存在や活力度が比較的はっきり現れます。
NDVIは、この性質を利用して植生の状態を数値化したものです。
葉の光学特性を説明するモデル ― PROSPECT
葉のスペクトル特性を物理的に説明する代表的なモデルが、Jacquemoud と Baret による PROSPECT モデルです。
PROSPECTは、葉の 400〜2500 nm の波長範囲における反射と透過を説明する放射伝達モデルで、葉の光学応答を次のような要素で表現します。
- クロロフィルなどの色素濃度
- 葉肉構造
- 屈折率
- 水分量
このモデルから分かる基本的な性質は、次の2点です。
- 赤領域では色素による吸収が支配的
- 近赤外領域では葉内部構造による散乱が支配的
NDVIは、この葉の基本的な光学応答を利用した植生指数だといえます。
実行方法
このサンプルは、ブラウザだけで動かせる単一の HTML ファイルです。
HTMLファイルの作り方
たとえば Windows では、次のように作成できます。
- フォルダを開く
- 何もない場所で右クリック
- 新規作成 を選ぶ
- テキスト ドキュメント を作成する
- ファイル名を
ndvi_viewer.htmlに変更する- 例:
新しいテキスト ドキュメント.txt→ndvi_viewer.html
- 例:
- 拡張子を変更してよいか確認されたら はい を選ぶ
※ 環境によっては右クリックメニューに直接 HTML ファイル が出ないことがあります。その場合は、いったんテキストファイルを作ってから .html に変更すればOKです。
コードの貼り付け
作成した ndvi_viewer.html をメモ帳や VS Code などで開き、以下のコードをそのままコピー&ペーストして保存してください。
実行方法
保存した ndvi_viewer.html をダブルクリックすれば、ブラウザで開けます。
画像を読み込む場合は、画面内のファイル選択から JPEG 画像を選んでください。
コード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDVI Viewer</title>
<style>
:root{
--bg:#08111d;
--panel:#0f1c2f;
--panel2:#13243d;
--line:#294264;
--text:#ecf4ff;
--muted:#a9bbd7;
}
*{box-sizing:border-box}
html,body{
margin:0;
background:linear-gradient(180deg,#07101b,#091423 40%,#08111d);
color:var(--text);
font-family:Inter,"Noto Sans JP",system-ui,sans-serif;
}
.app{
width:min(1600px,calc(100vw - 18px));
margin:10px auto 18px;
}
.topbar,.panel,.view,.footerPanel{
background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.025));
border:1px solid var(--line);
border-radius:16px;
box-shadow:0 10px 36px rgba(0,0,0,.24);
}
.topbar{
padding:12px 14px;
display:grid;
grid-template-columns:1fr auto auto auto;
gap:10px;
align-items:center;
margin-bottom:12px;
}
.title{
display:flex;
align-items:center;
gap:10px;
}
.title h1{
margin:0;
font-size:24px;
}
.subtitle{
font-size:12px;
color:var(--muted);
}
.badge{
display:inline-flex;
align-items:center;
gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid var(--line);
background:rgba(79,211,255,.08);
color:#d7f6ff;
font-size:12px;
white-space:nowrap;
}
.warn{
background:rgba(255,107,107,.08);
color:#ffdede;
}
.layout{
display:grid;
grid-template-columns:360px 1fr;
gap:12px;
}
.panel{
padding:14px;
position:sticky;
top:10px;
align-self:start;
}
.main{
display:grid;
gap:12px;
}
.section{
padding-bottom:14px;
margin-bottom:14px;
border-bottom:1px dashed rgba(255,255,255,.10);
}
.section:last-child{
padding-bottom:0;
margin-bottom:0;
border-bottom:0;
}
h2{
margin:0 0 10px;
font-size:17px;
}
h3{
margin:0 0 8px;
font-size:14px;
}
p{
margin:6px 0;
color:var(--muted);
}
.mono{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
.row{
display:grid;
grid-template-columns:1fr auto;
gap:10px;
align-items:center;
margin:8px 0;
}
.tiny{
font-size:12px;
color:var(--muted);
}
.value{
font-size:12px;
color:#e7f0ff;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
input[type="file"], select, button{
width:100%;
border-radius:12px;
border:1px solid var(--line);
background:var(--panel2);
color:var(--text);
padding:10px 12px;
}
input[type="range"]{
width:100%;
accent-color:#74bcff;
}
input[type="color"]{
width:44px;
height:32px;
border:0;
background:transparent;
}
button{
cursor:pointer;
font-weight:700;
}
.buttonRow{
display:grid;
grid-template-columns:1fr 1fr;
gap:10px;
}
.grid4{
display:grid;
grid-template-columns:repeat(2,minmax(280px,1fr));
gap:12px;
}
.view{
padding:10px;
}
.viewHead{
display:flex;
justify-content:space-between;
align-items:center;
gap:8px;
margin-bottom:8px;
}
.viewTitle{
font-weight:700;
}
.bandTag{
font-size:11px;
color:#d9eeff;
padding:4px 8px;
border-radius:999px;
border:1px solid var(--line);
background:rgba(255,255,255,.04);
}
canvas{
width:100%;
height:auto;
display:block;
border-radius:12px;
background:#050912;
border:1px solid rgba(255,255,255,.07);
}
.stats{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:10px;
}
.stat{
padding:12px;
border-radius:14px;
border:1px solid var(--line);
background:rgba(255,255,255,.03);
}
.stat .k{
font-size:12px;
color:var(--muted);
}
.stat .v{
margin-top:4px;
font-size:24px;
font-weight:800;
}
.bottom{
display:grid;
grid-template-columns:1.15fr .85fr;
gap:12px;
}
.footerPanel{
padding:12px;
}
.inspector{
display:grid;
grid-template-columns:repeat(2,1fr);
gap:10px;
}
.kv{
padding:10px;
border-radius:12px;
border:1px solid var(--line);
background:rgba(255,255,255,.03);
}
.kv .k{
font-size:12px;
color:var(--muted);
}
.kv .v{
margin-top:4px;
font-size:17px;
font-weight:700;
}
.legendWrap{
margin-top:8px;
}
.legendLabels{
display:flex;
justify-content:space-between;
margin-top:4px;
color:var(--muted);
font-size:12px;
}
.statusStrip{
display:grid;
grid-template-columns:repeat(3,1fr);
gap:8px;
margin-top:8px;
}
.pill{
padding:8px 10px;
border-radius:12px;
border:1px solid var(--line);
background:rgba(255,255,255,.03);
font-size:12px;
}
.formula{
padding:10px 12px;
border-radius:12px;
border:1px solid var(--line);
background:rgba(79,211,255,.06);
font-size:14px;
}
.swatches{
display:grid;
grid-template-columns:56px 1fr 76px;
gap:8px;
align-items:center;
margin:6px 0;
}
.note{
padding:10px 12px;
border-radius:12px;
border:1px solid rgba(255,209,102,.28);
background:rgba(255,209,102,.06);
color:#fff2cc;
font-size:13px;
}
.specLine{
display:flex;
justify-content:space-between;
gap:8px;
font-size:12px;
color:var(--muted);
margin-top:6px;
}
.noticeBox{
padding:12px;
border-radius:14px;
border:1px solid var(--line);
background:rgba(255,255,255,.03);
margin-top:10px;
}
.noticeBox p{
margin:8px 0;
font-size:13px;
}
@media (max-width:1180px){
.layout{grid-template-columns:1fr}
.panel{position:static}
.bottom{grid-template-columns:1fr}
.stats{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:760px){
.topbar{grid-template-columns:1fr}
.grid4{grid-template-columns:1fr}
.stats,.inspector,.statusStrip{grid-template-columns:1fr}
}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div class="title">
<div>
<h1>NDVI Viewer</h1>
<div class="subtitle">JPEG 1枚から、Red* / NIR* / CIR / NDVI を並べて見るための表示</div>
</div>
</div>
<div class="badge">Band Math: <span class="mono">(NIR* - Red*) / (NIR* + Red*)</span></div>
<div class="badge">CIR: <span class="mono">R=NIR*</span> <span class="mono">G=Red*</span> <span class="mono">B=Green</span></div>
<div class="badge warn">NIR* は推定値</div>
</div>
<div class="layout">
<aside class="panel">
<div class="section">
<h2>1. 画像</h2>
<input id="fileInput" type="file" accept="image/*" />
<div class="tiny" style="margin-top:8px">同じフォルダに <span class="mono">hatu3_3_1.JPG</span> を置けば自動読込します。</div>
</div>
<div class="section">
<h2>2. 設定</h2>
<div class="row"><label>Red band center</label><div class="value">660 nm</div></div>
<div class="row"><label>NIR band center</label><div class="value">800 nm</div></div>
<div class="row"><label>Input encoding</label><div class="value">sRGB JPEG → linearized</div></div>
<div class="row"><label>Output mode</label><div class="value">proxy reflectance</div></div>
<div class="note">Red* と NIR* は JPEG の RGB から求めた代理値です。</div>
</div>
<div class="section">
<h2>3. パラメータ</h2>
<div class="row"><label for="vegNIR">Healthy vegetation NIR*</label><div id="vegNIRVal" class="value">0.62</div></div>
<input id="vegNIR" type="range" min="0.40" max="0.85" step="0.01" value="0.62">
<div class="row"><label for="stressNIR">Stressed vegetation NIR*</label><div id="stressNIRVal" class="value">0.42</div></div>
<input id="stressNIR" type="range" min="0.20" max="0.65" step="0.01" value="0.42">
<div class="row"><label for="soilNIR">Soil / non-veg NIR*</label><div id="soilNIRVal" class="value">0.28</div></div>
<input id="soilNIR" type="range" min="0.10" max="0.50" step="0.01" value="0.28">
<div class="row"><label for="waterNIR">Water / shadow NIR*</label><div id="waterNIRVal" class="value">0.03</div></div>
<input id="waterNIR" type="range" min="0.00" max="0.12" step="0.005" value="0.03">
<div class="row"><label for="vegSensitivity">Vegetation classifier gain</label><div id="vegSensitivityVal" class="value">2.20</div></div>
<input id="vegSensitivity" type="range" min="0.80" max="5.00" step="0.05" value="2.20">
<div class="row"><label for="shadowComp">Illumination compensation</label><div id="shadowCompVal" class="value">0.45</div></div>
<input id="shadowComp" type="range" min="0.00" max="1.00" step="0.01" value="0.45">
</div>
<div class="section">
<h2>4. NDVI 表示</h2>
<div class="row"><label for="presetSelect">Palette preset</label><div></div></div>
<select id="presetSelect">
<option value="classic">Classic NDVI</option>
<option value="agronomy">Agronomy</option>
<option value="thermal">Scientific Heat</option>
<option value="waterveg">Water → Vegetation</option>
</select>
<div class="swatches"><span class="tiny">−0.2</span><input id="c0" type="color" value="#1b2f8a"><div id="c0Val" class="value">#1B2F8A</div></div>
<div class="swatches"><span class="tiny">0.0</span><input id="c1" type="color" value="#4b7bec"><div id="c1Val" class="value">#4B7BEC</div></div>
<div class="swatches"><span class="tiny">0.2</span><input id="c2" type="color" value="#f5d547"><div id="c2Val" class="value">#F5D547</div></div>
<div class="swatches"><span class="tiny">0.5</span><input id="c3" type="color" value="#4cd97b"><div id="c3Val" class="value">#4CD97B</div></div>
<div class="swatches"><span class="tiny">0.8</span><input id="c4" type="color" value="#0a5c2b"><div id="c4Val" class="value">#0A5C2B</div></div>
<div class="row"><label for="textureMix">Texture blend</label><div id="textureMixVal" class="value">0.35</div></div>
<input id="textureMix" type="range" min="0" max="1" step="0.01" value="0.35">
<div class="buttonRow" style="margin-top:10px">
<button id="recalcBtn">再計算</button>
<button id="resetBtn">初期値に戻す</button>
</div>
</div>
<div class="section">
<h2>5. 式</h2>
<div class="formula mono">NDVI = (NIR* − Red*) / (NIR* + Red*)</div>
<div class="specLine"><span>Vegetation prior</span><span class="mono">ExG + NGRDI + green dominance</span></div>
<div class="specLine"><span>Water / shadow prior</span><span class="mono">darkness + blue dominance</span></div>
<div class="specLine"><span>CIR composite</span><span class="mono">RGB = [NIR*, Red*, Green]</span></div>
</div>
</aside>
<main class="main">
<div class="grid4">
<div class="view">
<div class="viewHead"><div class="viewTitle">RGB scene</div><div class="bandTag">VIS composite</div></div>
<canvas id="rgbCanvas" width="960" height="720"></canvas>
</div>
<div class="view">
<div class="viewHead"><div class="viewTitle">NIR* proxy</div><div class="bandTag">800 nm</div></div>
<canvas id="nirCanvas" width="960" height="720"></canvas>
</div>
<div class="view">
<div class="viewHead"><div class="viewTitle">False Color CIR</div><div class="bandTag">R=NIR* / G=Red* / B=Green</div></div>
<canvas id="cirCanvas" width="960" height="720"></canvas>
</div>
<div class="view">
<div class="viewHead"><div class="viewTitle">NDVI map</div><div class="bandTag">Band math</div></div>
<canvas id="ndviCanvas" width="960" height="720"></canvas>
<div class="legendWrap">
<canvas id="legendCanvas" width="860" height="26"></canvas>
<div class="legendLabels"><span>−0.2</span><span>0.0</span><span>0.2</span><span>0.5</span><span>0.8</span></div>
</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">Mean NDVI</div><div id="meanNdvi" class="v">—</div></div>
<div class="stat"><div class="k">High vegetation > 0.5</div><div id="highVeg" class="v">—</div></div>
<div class="stat"><div class="k">Low NDVI < 0</div><div id="lowNdvi" class="v">—</div></div>
<div class="stat"><div class="k">Image size</div><div id="imgSize" class="v">—</div></div>
<div class="stat"><div class="k">Pixel count</div><div id="pixCount" class="v">—</div></div>
</div>
<div class="bottom">
<div class="footerPanel">
<h2>分布とスペクトル</h2>
<canvas id="histCanvas" width="980" height="300"></canvas>
<div style="height:10px"></div>
<canvas id="spectraCanvas" width="980" height="360"></canvas>
<div class="statusStrip">
<div class="pill">表示中のスペクトルは <span class="mono">Green / Red* / NIR*</span> を並べた代理曲線です。</div>
<div class="pill">CIR は <span class="mono">NIR*, Red*, Green</span> を RGB に割り当てています。</div>
<div class="pill">カーソル位置の画素値を右側に表示します。</div>
</div>
<div class="noticeBox">
<h3>注意点</h3>
<p>この画面の NIR* は、JPEG に本物の近赤外バンドが入っていないため、RGB から作った代理値です。</p>
<p>そのため、ここで表示される NDVI は説明用の表示であり、マルチスペクトルカメラで測った実測 NDVI そのものではありません。</p>
<p>同じ見た目の緑でも、実際の葉の内部構造や水分状態が違うと NIR 反射は変わるため、RGB だけから真の NDVI を一意には求められません。</p>
</div>
</div>
<div class="footerPanel">
<h2>画素</h2>
<div class="inspector">
<div class="kv"><div class="k">x, y</div><div id="pxXY" class="v">—</div></div>
<div class="kv"><div class="k">sRGB</div><div id="pxRGB" class="v">—</div></div>
<div class="kv"><div class="k">Green proxy</div><div id="pxGreen" class="v">—</div></div>
<div class="kv"><div class="k">Red*</div><div id="pxRed" class="v">—</div></div>
<div class="kv"><div class="k">NIR*</div><div id="pxNIR" class="v">—</div></div>
<div class="kv"><div class="k">NDVI</div><div id="pxNDVI" class="v">—</div></div>
<div class="kv"><div class="k">Vegetation probability</div><div id="pxVeg" class="v">—</div></div>
<div class="kv"><div class="k">Water / shadow probability</div><div id="pxWater" class="v">—</div></div>
<div class="kv"><div class="k">Class</div><div id="pxClass" class="v">—</div></div>
<div class="kv"><div class="k">Green dominance</div><div id="pxGreenDom" class="v">—</div></div>
</div>
</div>
</div>
</main>
</div>
</div>
<img id="autoImg" alt="sample" hidden>
<script>
(() => {
'use strict';
const paletteStops = [-0.2, 0.0, 0.2, 0.5, 0.8];
const presets = {
classic: ['#1B2F8A', '#4B7BEC', '#F5D547', '#4CD97B', '#0A5C2B'],
agronomy: ['#263859', '#4E8EC2', '#B8D56A', '#5CB85C', '#224B0C'],
thermal: ['#2A1A5E', '#8A2BE2', '#F7B733', '#8BC34A', '#1E5631'],
waterveg: ['#082567', '#38A3F1', '#ECDD5B', '#78D64B', '#0A4B1E']
};
const $ = id => document.getElementById(id);
const rgbCanvas = $('rgbCanvas');
const nirCanvas = $('nirCanvas');
const cirCanvas = $('cirCanvas');
const ndviCanvas = $('ndviCanvas');
const histCanvas = $('histCanvas');
const spectraCanvas = $('spectraCanvas');
const legendCanvas = $('legendCanvas');
const rgbCtx = rgbCanvas.getContext('2d', { willReadFrequently: true });
const nirCtx = nirCanvas.getContext('2d');
const cirCtx = cirCanvas.getContext('2d');
const ndviCtx = ndviCanvas.getContext('2d');
const histCtx = histCanvas.getContext('2d');
const spectraCtx = spectraCanvas.getContext('2d');
const legendCtx = legendCanvas.getContext('2d');
const controls = {
fileInput: $('fileInput'),
vegNIR: $('vegNIR'),
stressNIR: $('stressNIR'),
soilNIR: $('soilNIR'),
waterNIR: $('waterNIR'),
vegSensitivity: $('vegSensitivity'),
shadowComp: $('shadowComp'),
textureMix: $('textureMix'),
presetSelect: $('presetSelect'),
recalcBtn: $('recalcBtn'),
resetBtn: $('resetBtn'),
c0: $('c0'),
c1: $('c1'),
c2: $('c2'),
c3: $('c3'),
c4: $('c4')
};
const labels = {
vegNIRVal: $('vegNIRVal'),
stressNIRVal: $('stressNIRVal'),
soilNIRVal: $('soilNIRVal'),
waterNIRVal: $('waterNIRVal'),
vegSensitivityVal: $('vegSensitivityVal'),
shadowCompVal: $('shadowCompVal'),
textureMixVal: $('textureMixVal'),
c0Val: $('c0Val'),
c1Val: $('c1Val'),
c2Val: $('c2Val'),
c3Val: $('c3Val'),
c4Val: $('c4Val')
};
const readouts = {
meanNdvi: $('meanNdvi'),
highVeg: $('highVeg'),
lowNdvi: $('lowNdvi'),
imgSize: $('imgSize'),
pixCount: $('pixCount'),
pxXY: $('pxXY'),
pxRGB: $('pxRGB'),
pxGreen: $('pxGreen'),
pxRed: $('pxRed'),
pxNIR: $('pxNIR'),
pxNDVI: $('pxNDVI'),
pxVeg: $('pxVeg'),
pxWater: $('pxWater'),
pxClass: $('pxClass'),
pxGreenDom: $('pxGreenDom')
};
const state = {
width: 0,
height: 0,
original: null,
imageData: null,
green: null,
red: null,
nir: null,
ndvi: null,
veg: null,
water: null,
greenDom: null,
classId: null,
hoveredIndex: null
};
function clamp(x, lo, hi) {
return Math.min(hi, Math.max(lo, x));
}
function mix(a, b, t) {
return a * (1 - t) + b * t;
}
function sigmoid(x) {
return 1 / (1 + Math.exp(-x));
}
function srgbToLinear(v255) {
const v = v255 / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSrgb(v) {
const x = clamp(v, 0, 1);
const s = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
return Math.round(clamp(s, 0, 1) * 255);
}
function hexToRgb(hex) {
const s = hex.replace('#', '');
return {
r: parseInt(s.slice(0, 2), 16),
g: parseInt(s.slice(2, 4), 16),
b: parseInt(s.slice(4, 6), 16)
};
}
function currentPalette() {
return [
controls.c0.value,
controls.c1.value,
controls.c2.value,
controls.c3.value,
controls.c4.value
].map(hexToRgb);
}
function paletteColor(v) {
const colors = currentPalette();
if (v <= paletteStops[0]) return colors[0];
if (v >= paletteStops[paletteStops.length - 1]) return colors[colors.length - 1];
for (let i = 0; i < paletteStops.length - 1; i++) {
const a = paletteStops[i];
const b = paletteStops[i + 1];
if (v >= a && v <= b) {
const t = (v - a) / (b - a + 1e-9);
return {
r: Math.round(mix(colors[i].r, colors[i + 1].r, t)),
g: Math.round(mix(colors[i].g, colors[i + 1].g, t)),
b: Math.round(mix(colors[i].b, colors[i + 1].b, t))
};
}
}
return colors[colors.length - 1];
}
function updateLabels() {
labels.vegNIRVal.textContent = Number(controls.vegNIR.value).toFixed(2);
labels.stressNIRVal.textContent = Number(controls.stressNIR.value).toFixed(2);
labels.soilNIRVal.textContent = Number(controls.soilNIR.value).toFixed(2);
labels.waterNIRVal.textContent = Number(controls.waterNIR.value).toFixed(3);
labels.vegSensitivityVal.textContent = Number(controls.vegSensitivity.value).toFixed(2);
labels.shadowCompVal.textContent = Number(controls.shadowComp.value).toFixed(2);
labels.textureMixVal.textContent = Number(controls.textureMix.value).toFixed(2);
labels.c0Val.textContent = controls.c0.value.toUpperCase();
labels.c1Val.textContent = controls.c1.value.toUpperCase();
labels.c2Val.textContent = controls.c2.value.toUpperCase();
labels.c3Val.textContent = controls.c3.value.toUpperCase();
labels.c4Val.textContent = controls.c4.value.toUpperCase();
}
function applyPreset(name) {
const p = presets[name] || presets.classic;
controls.c0.value = p[0];
controls.c1.value = p[1];
controls.c2.value = p[2];
controls.c3.value = p[3];
controls.c4.value = p[4];
updateLabels();
drawLegend();
if (state.imageData) renderNdvi();
}
function resetControls() {
controls.vegNIR.value = '0.62';
controls.stressNIR.value = '0.42';
controls.soilNIR.value = '0.28';
controls.waterNIR.value = '0.03';
controls.vegSensitivity.value = '2.20';
controls.shadowComp.value = '0.45';
controls.textureMix.value = '0.35';
controls.presetSelect.value = 'classic';
applyPreset('classic');
updateLabels();
if (state.imageData) computeAndRender();
}
function drawLegend() {
const w = legendCanvas.width;
const h = legendCanvas.height;
legendCtx.clearRect(0, 0, w, h);
const grad = legendCtx.createLinearGradient(0, 0, w, 0);
const cols = currentPalette();
cols.forEach((c, i) => {
const t = (paletteStops[i] - paletteStops[0]) / (paletteStops[paletteStops.length - 1] - paletteStops[0]);
grad.addColorStop(clamp(t, 0, 1), `rgb(${c.r},${c.g},${c.b})`);
});
legendCtx.fillStyle = grad;
legendCtx.fillRect(0, 0, w, h);
legendCtx.strokeStyle = 'rgba(255,255,255,.8)';
legendCtx.strokeRect(.5, .5, w - 1, h - 1);
}
function className(id) {
return ({1:'Vegetation',2:'Mixed vegetation',3:'Water / shadow',4:'Non-vegetation'})[id] || '—';
}
function loadImage(img) {
const maxDim = 1200;
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth || img.width, img.naturalHeight || img.height));
const w = Math.max(1, Math.round((img.naturalWidth || img.width) * scale));
const h = Math.max(1, Math.round((img.naturalHeight || img.height) * scale));
[rgbCanvas, nirCanvas, cirCanvas, ndviCanvas].forEach(c => {
c.width = w;
c.height = h;
});
rgbCtx.clearRect(0, 0, w, h);
rgbCtx.drawImage(img, 0, 0, w, h);
state.width = w;
state.height = h;
state.imageData = rgbCtx.getImageData(0, 0, w, h);
state.original = new Uint8ClampedArray(state.imageData.data);
readouts.imgSize.textContent = `${w}×${h}`;
readouts.pixCount.textContent = String(w * h);
computeAndRender();
}
function computeAndRender() {
if (!state.imageData) return;
const src = state.imageData.data;
const n = state.width * state.height;
const green = new Float32Array(n);
const red = new Float32Array(n);
const nir = new Float32Array(n);
const ndvi = new Float32Array(n);
const veg = new Float32Array(n);
const water = new Float32Array(n);
const greenDom = new Float32Array(n);
const classId = new Uint8Array(n);
const vegNIR = parseFloat(controls.vegNIR.value);
const stressNIR = parseFloat(controls.stressNIR.value);
const soilNIR = parseFloat(controls.soilNIR.value);
const waterNIR = parseFloat(controls.waterNIR.value);
const sensitivity = parseFloat(controls.vegSensitivity.value);
const shadowComp = parseFloat(controls.shadowComp.value);
let sumNdvi = 0;
let high = 0;
let low = 0;
let nirMin = 1e9;
let nirMax = -1e9;
for (let i = 0, p = 0; i < src.length; i += 4, p++) {
const Rs = src[i];
const Gs = src[i + 1];
const Bs = src[i + 2];
const R = srgbToLinear(Rs);
const G = srgbToLinear(Gs);
const B = srgbToLinear(Bs);
const sum = R + G + B + 1e-6;
const brightness = sum / 3;
const rN = R / sum;
const gN = G / sum;
const bN = B / sum;
const sat = (Math.max(R, G, B) - Math.min(R, G, B)) / (Math.max(R, G, B) + 1e-6);
const exg = 2 * G - R - B;
const ngrdi = (G - R) / (G + R + 1e-6);
const gdom = gN - rN;
const dark = sigmoid((0.16 - brightness) * 12);
const wetBlue = bN - gN;
const greenness = clamp((ngrdi + 0.12) / 0.50, 0, 1);
let vegProb = sigmoid(sensitivity * (2.25 * exg + 1.70 * ngrdi + 1.35 * gdom + 0.45 * sat - 0.10));
let waterProb = sigmoid(4.4 * (0.18 - brightness) + 1.5 * wetBlue + 1.1 * (0.10 - exg) + 0.8 * (0.22 - sat));
let nonVeg = clamp(1 - 0.78 * vegProb - 0.88 * waterProb, 0.02, 1.0);
const norm = vegProb + waterProb + nonVeg;
vegProb /= norm;
waterProb /= norm;
nonVeg /= norm;
const greenModel = clamp(0.02 + 0.82 * G, 0, 0.8);
const redVeg = mix(0.12, 0.035, greenness);
const redNonVeg = clamp(0.10 + 0.38 * (0.70 * R + 0.20 * G + 0.10 * B), 0.08, 0.45);
const redWater = clamp(0.008 + 0.12 * R + 0.04 * brightness, 0.005, 0.14);
let redModel = vegProb * redVeg + waterProb * redWater + nonVeg * redNonVeg;
redModel = mix(redModel, clamp(0.70 * R + 0.15 * G + 0.15 * B, 0, 1), 0.22 * (1 - shadowComp));
let nirVeg = mix(stressNIR, vegNIR, greenness);
let nirNonVeg = soilNIR * mix(0.85, 1.15, clamp(brightness, 0, 1));
let nirWaterVal = waterNIR * mix(0.70, 1.00, 1 - dark);
let nirModel = vegProb * nirVeg + waterProb * nirWaterVal + nonVeg * nirNonVeg;
const illumPenalty = 1 - (1 - shadowComp) * 0.55 * dark;
nirModel *= illumPenalty;
redModel *= 1 - (1 - shadowComp) * 0.35 * dark;
const nd = clamp((nirModel - redModel) / (nirModel + redModel + 1e-6), -1, 1);
green[p] = greenModel;
red[p] = redModel;
nir[p] = nirModel;
ndvi[p] = nd;
veg[p] = vegProb;
water[p] = waterProb;
greenDom[p] = gdom;
if (waterProb > 0.45 || nd < 0) classId[p] = 3;
else if (vegProb > 0.55 && greenness > 0.55) classId[p] = 1;
else if (vegProb > 0.38) classId[p] = 2;
else classId[p] = 4;
sumNdvi += nd;
if (nd > 0.5) high++;
if (nd < 0) low++;
nirMin = Math.min(nirMin, nirModel);
nirMax = Math.max(nirMax, nirModel);
}
state.green = green;
state.red = red;
state.nir = nir;
state.ndvi = ndvi;
state.veg = veg;
state.water = water;
state.greenDom = greenDom;
state.classId = classId;
readouts.meanNdvi.textContent = (sumNdvi / n).toFixed(3);
readouts.highVeg.textContent = (100 * high / n).toFixed(1) + '%';
readouts.lowNdvi.textContent = (100 * low / n).toFixed(1) + '%';
renderNIR(nirMin, nirMax);
renderCIR();
renderNdvi();
drawHistogram();
drawSpectra();
updateInspector(Math.floor(state.width / 2), Math.floor(state.height / 2));
}
function renderNIR(minV, maxV) {
const img = nirCtx.createImageData(state.width, state.height);
const out = img.data;
const span = Math.max(1e-6, maxV - minV);
for (let p = 0, i = 0; p < state.nir.length; p++, i += 4) {
const t = Math.round(255 * clamp((state.nir[p] - minV) / span, 0, 1));
out[i] = t;
out[i + 1] = t;
out[i + 2] = t;
out[i + 3] = 255;
}
nirCtx.putImageData(img, 0, 0);
}
function renderCIR() {
const img = cirCtx.createImageData(state.width, state.height);
const out = img.data;
for (let p = 0, i = 0; p < state.nir.length; p++, i += 4) {
out[i] = linearToSrgb(state.nir[p]);
out[i + 1] = linearToSrgb(state.red[p]);
out[i + 2] = linearToSrgb(state.green[p]);
out[i + 3] = 255;
}
cirCtx.putImageData(img, 0, 0);
}
function renderNdvi() {
const img = ndviCtx.createImageData(state.width, state.height);
const out = img.data;
const tex = parseFloat(controls.textureMix.value);
for (let p = 0, i = 0; p < state.ndvi.length; p++, i += 4) {
const c = paletteColor(state.ndvi[p]);
const gray = 0.2126 * state.original[i] + 0.7152 * state.original[i + 1] + 0.0722 * state.original[i + 2];
const shade = 0.50 + 0.75 * (gray / 255);
out[i] = Math.round(clamp(mix(c.r, c.r * shade, tex), 0, 255));
out[i + 1] = Math.round(clamp(mix(c.g, c.g * shade, tex), 0, 255));
out[i + 2] = Math.round(clamp(mix(c.b, c.b * shade, tex), 0, 255));
out[i + 3] = 255;
}
ndviCtx.putImageData(img, 0, 0);
drawLegend();
}
function drawHistogram() {
const w = histCanvas.width;
const h = histCanvas.height;
histCtx.clearRect(0, 0, w, h);
histCtx.fillStyle = '#0a1321';
histCtx.fillRect(0, 0, w, h);
const left = 48;
const right = 12;
const top = 14;
const bottom = 28;
const plotW = w - left - right;
const plotH = h - top - bottom;
const bins = 64;
const hist = new Uint32Array(bins);
for (let i = 0; i < state.ndvi.length; i++) {
const idx = clamp(Math.floor(((state.ndvi[i] + 1) / 2) * bins), 0, bins - 1);
hist[idx]++;
}
const maxCount = Math.max(...hist, 1);
histCtx.strokeStyle = 'rgba(255,255,255,.12)';
for (let t = 0; t <= 4; t++) {
const y = top + (plotH / 4) * t;
histCtx.beginPath();
histCtx.moveTo(left, y);
histCtx.lineTo(left + plotW, y);
histCtx.stroke();
}
const barW = plotW / bins;
for (let b = 0; b < bins; b++) {
const x = left + b * barW;
const hh = (hist[b] / maxCount) * plotH;
const center = ((b + 0.5) / bins) * 2 - 1;
const c = paletteColor(center);
histCtx.fillStyle = `rgb(${c.r},${c.g},${c.b})`;
histCtx.fillRect(x, top + plotH - hh, Math.max(1, barW - 1), hh);
}
histCtx.strokeStyle = 'rgba(255,255,255,.5)';
histCtx.beginPath();
histCtx.moveTo(left, top);
histCtx.lineTo(left, top + plotH);
histCtx.lineTo(left + plotW, top + plotH);
histCtx.stroke();
histCtx.fillStyle = 'rgba(255,255,255,.75)';
histCtx.font = '12px sans-serif';
[-1, -0.5, 0, 0.5, 1].forEach(v => {
const x = left + ((v + 1) / 2) * plotW;
histCtx.fillText(v.toFixed(1), x - 12, h - 8);
});
histCtx.fillText('NDVI', w / 2 - 14, h - 8);
}
function drawSpectra() {
const w = spectraCanvas.width;
const h = spectraCanvas.height;
spectraCtx.clearRect(0, 0, w, h);
spectraCtx.fillStyle = '#0a1321';
spectraCtx.fillRect(0, 0, w, h);
const left = 52;
const right = 20;
const top = 18;
const bottom = 58;
const plotW = w - left - right;
const plotH = h - top - bottom;
const x = wl => left + ((wl - 500) / 320) * plotW;
const y = v => top + (1 - clamp(v / 0.75, 0, 1)) * plotH;
spectraCtx.strokeStyle = 'rgba(255,255,255,.12)';
for (let i = 0; i <= 5; i++) {
const yy = top + (plotH / 5) * i;
spectraCtx.beginPath();
spectraCtx.moveTo(left, yy);
spectraCtx.lineTo(left + plotW, yy);
spectraCtx.stroke();
}
[550, 660, 800].forEach(wl => {
const xx = x(wl);
spectraCtx.beginPath();
spectraCtx.moveTo(xx, top);
spectraCtx.lineTo(xx, top + plotH);
spectraCtx.stroke();
});
spectraCtx.strokeStyle = 'rgba(255,255,255,.55)';
spectraCtx.beginPath();
spectraCtx.moveTo(left, top);
spectraCtx.lineTo(left, top + plotH);
spectraCtx.lineTo(left + plotW, top + plotH);
spectraCtx.stroke();
const refs = [
{ label: 'Vegetation', color: '#65e28f', pts: [[550,0.12],[660,0.04],[800,0.58]] },
{ label: 'Stressed vegetation', color: '#ffd166', pts: [[550,0.10],[660,0.10],[800,0.40]] },
{ label: 'Soil', color: '#c89f67', pts: [[550,0.21],[660,0.25],[800,0.33]] },
{ label: 'Water', color: '#62a7ff', pts: [[550,0.03],[660,0.02],[800,0.005]] }
];
refs.forEach(ref => {
spectraCtx.strokeStyle = ref.color;
spectraCtx.lineWidth = 2;
spectraCtx.beginPath();
ref.pts.forEach((pt, i) => {
if (i === 0) spectraCtx.moveTo(x(pt[0]), y(pt[1]));
else spectraCtx.lineTo(x(pt[0]), y(pt[1]));
});
spectraCtx.stroke();
});
if (state.hoveredIndex !== null) {
const idx = state.hoveredIndex;
const pts = [
[550, state.green[idx]],
[660, state.red[idx]],
[800, state.nir[idx]]
];
spectraCtx.strokeStyle = '#ffffff';
spectraCtx.lineWidth = 3;
spectraCtx.beginPath();
pts.forEach((pt, i) => {
if (i === 0) spectraCtx.moveTo(x(pt[0]), y(pt[1]));
else spectraCtx.lineTo(x(pt[0]), y(pt[1]));
});
spectraCtx.stroke();
pts.forEach(pt => {
spectraCtx.fillStyle = '#ffffff';
spectraCtx.beginPath();
spectraCtx.arc(x(pt[0]), y(pt[1]), 4, 0, Math.PI * 2);
spectraCtx.fill();
});
}
spectraCtx.fillStyle = 'rgba(255,255,255,.78)';
spectraCtx.font = '12px sans-serif';
[550, 660, 800].forEach(wl => {
const xx = x(wl);
spectraCtx.textAlign = 'center';
spectraCtx.fillText(String(wl), xx, h - 34);
});
[0.0, 0.2, 0.4, 0.6].forEach(v => {
spectraCtx.textAlign = 'left';
spectraCtx.fillText(v.toFixed(1), 14, y(v) + 4);
});
spectraCtx.textAlign = 'center';
spectraCtx.fillText('wavelength (nm)', w / 2, h - 10);
spectraCtx.save();
spectraCtx.translate(18, h / 2 + 12);
spectraCtx.rotate(-Math.PI / 2);
spectraCtx.fillText('proxy reflectance', 0, 0);
spectraCtx.restore();
}
function eventToPixel(ev, canvas) {
const rect = canvas.getBoundingClientRect();
const x = Math.floor((ev.clientX - rect.left) * canvas.width / rect.width);
const y = Math.floor((ev.clientY - rect.top) * canvas.height / rect.height);
return {
x: clamp(x, 0, canvas.width - 1),
y: clamp(y, 0, canvas.height - 1)
};
}
function updateInspector(x, y) {
if (!state.imageData) return;
const idx = y * state.width + x;
state.hoveredIndex = idx;
const i = idx * 4;
readouts.pxXY.textContent = `${x}, ${y}`;
readouts.pxRGB.textContent = `${state.original[i]}, ${state.original[i + 1]}, ${state.original[i + 2]}`;
readouts.pxGreen.textContent = state.green[idx].toFixed(3);
readouts.pxRed.textContent = state.red[idx].toFixed(3);
readouts.pxNIR.textContent = state.nir[idx].toFixed(3);
readouts.pxNDVI.textContent = state.ndvi[idx].toFixed(3);
readouts.pxVeg.textContent = state.veg[idx].toFixed(3);
readouts.pxWater.textContent = state.water[idx].toFixed(3);
readouts.pxClass.textContent = className(state.classId[idx]);
readouts.pxGreenDom.textContent = state.greenDom[idx].toFixed(3);
drawSpectra();
}
function attachInspector(canvas) {
['mousemove', 'click'].forEach(evt => {
canvas.addEventListener(evt, e => {
if (!state.imageData) return;
const p = eventToPixel(e, canvas);
updateInspector(p.x, p.y);
});
});
}
function autoLoad() {
const img = $('autoImg');
img.onload = () => loadImage(img);
img.onerror = () => {};
img.src = 'hatu3_3_1.JPG';
}
controls.fileInput.addEventListener('change', e => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => loadImage(img);
img.src = URL.createObjectURL(file);
});
[
controls.vegNIR,
controls.stressNIR,
controls.soilNIR,
controls.waterNIR,
controls.vegSensitivity,
controls.shadowComp,
controls.textureMix,
controls.c0,
controls.c1,
controls.c2,
controls.c3,
controls.c4
].forEach(el => {
el.addEventListener('input', () => {
updateLabels();
if (el.type === 'color' || el === controls.textureMix) {
renderNdvi();
}
});
});
controls.recalcBtn.addEventListener('click', computeAndRender);
controls.resetBtn.addEventListener('click', resetControls);
controls.presetSelect.addEventListener('change', e => applyPreset(e.target.value));
[rgbCanvas, nirCanvas, cirCanvas, ndviCanvas].forEach(attachInspector);
updateLabels();
applyPreset('classic');
drawLegend();
drawSpectra();
autoLoad();
})();
</script>
</body>
</html>