feat: projeta Via Láctea fotográfica em alta resolução
This commit is contained in:
parent
ea74b4f6cd
commit
ec9814300a
3 changed files with 222 additions and 195 deletions
|
|
@ -1264,195 +1264,133 @@ function glareFactor(x, y, W, H, on) {
|
|||
return Math.max(0, Math.min(1, (d - 150) / 240));
|
||||
}
|
||||
|
||||
/* Transposta da matriz equatorial->galáctica (Hipparcos, J2000) */
|
||||
const G2E = [
|
||||
[-0.0548755604, 0.4941094279, -0.8676661490],
|
||||
[-0.8734370902, -0.4448296300, -0.1980763734],
|
||||
[-0.4838350155, 0.7469822445, 0.4559837762],
|
||||
/* Matriz equatorial -> galáctica (Hipparcos, J2000) */
|
||||
const E2G = [
|
||||
[-0.0548755604, -0.8734370902, -0.4838350155],
|
||||
[ 0.4941094279, -0.4448296300, 0.7469822445],
|
||||
[-0.8676661490, -0.1980763734, 0.4559837762],
|
||||
];
|
||||
|
||||
/* ── Via Láctea procedural contínua ──
|
||||
A textura é calculada no espaço galáctico em uma camada de baixa resolução.
|
||||
Assim a faixa, o bojo e a poeira acompanham a projeção real sem denunciar
|
||||
sprites circulares ou custar um passe em resolução cheia. */
|
||||
const MW_RENDER_SCALE = 0.50;
|
||||
const MW_NOISE_PERIODS = [5, 10, 20, 40, 80];
|
||||
const mwLayer = document.createElement('canvas');
|
||||
/* ── Via Láctea fotográfica em alta resolução ──
|
||||
"Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO), reprojetada
|
||||
para plate carrée por tools/build_skymap.py. A geometria é real; apenas
|
||||
exposição, contraste e saturação são estilizados para o wallpaper. */
|
||||
const MW_BLACK = 46;
|
||||
const MW_EXPOSURE = 1.16;
|
||||
const MW_SAMPLE_SCALE = 1;
|
||||
const mwTex = { data: null, w: 0, h: 0 };
|
||||
const mwCanvas = document.createElement('canvas');
|
||||
let mwGrid = null;
|
||||
|
||||
function mwHash(ix, iy) {
|
||||
let h = Math.imul(ix, 374761393) + Math.imul(iy, 668265263);
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
||||
}
|
||||
const mwImg = new Image();
|
||||
mwImg.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = mwImg.width;
|
||||
c.height = mwImg.height;
|
||||
const s = c.getContext('2d', { willReadFrequently: true });
|
||||
s.drawImage(mwImg, 0, 0);
|
||||
mwTex.data = s.getImageData(0, 0, c.width, c.height).data;
|
||||
mwTex.w = c.width;
|
||||
mwTex.h = c.height;
|
||||
projectSky();
|
||||
};
|
||||
mwImg.onerror = () => console.error('[skeledance] falha ao carregar milkyway.jpg');
|
||||
mwImg.src = 'milkyway.jpg';
|
||||
|
||||
function mwValueNoise(x, y, periodX) {
|
||||
const fx = Math.floor(x), fy = Math.floor(y);
|
||||
const tx0 = x - fx, ty0 = y - fy;
|
||||
const tx = tx0 * tx0 * (3 - 2 * tx0);
|
||||
const ty = ty0 * ty0 * (3 - 2 * ty0);
|
||||
const x0 = ((fx % periodX) + periodX) % periodX;
|
||||
const x1 = (x0 + 1) % periodX;
|
||||
const a = mwHash(x0, fy);
|
||||
const b = mwHash(x1, fy);
|
||||
const c = mwHash(x0, fy + 1);
|
||||
const d = mwHash(x1, fy + 1);
|
||||
const top = a + (b - a) * tx;
|
||||
const bot = c + (d - c) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
/* Ruído periódico em longitude: não deixa emenda em l=0°/360°. */
|
||||
function mwFbm(l, b) {
|
||||
let value = 0, norm = 0, amp = 1;
|
||||
for (const period of MW_NOISE_PERIODS) {
|
||||
value += amp * mwValueNoise(l / 360 * period, (b + 90) / 360 * period, period);
|
||||
norm += amp;
|
||||
amp *= 0.50;
|
||||
}
|
||||
return value / norm;
|
||||
}
|
||||
|
||||
function mwFineNoise(l, b) {
|
||||
const n1 = mwValueNoise(l / 360 * 160, (b + 90) / 360 * 160, 160);
|
||||
const n2 = mwValueNoise(l / 360 * 320, (b + 90) / 360 * 320, 320);
|
||||
return n1 * 0.68 + n2 * 0.32;
|
||||
}
|
||||
|
||||
function mwAngularDistance(l, target) {
|
||||
const d = Math.abs(l - target);
|
||||
return Math.min(d, 360 - d);
|
||||
}
|
||||
|
||||
function mwCloud(l, b, cl, cb, wl, wb, strength) {
|
||||
const x = mwAngularDistance(l, cl) / wl;
|
||||
const y = (b - cb) / wb;
|
||||
return strength * Math.exp(-0.5 * (x * x + y * y));
|
||||
}
|
||||
|
||||
/* Densidade luminosa em coordenadas galácticas. O detalhe vem do ruído,
|
||||
enquanto as formas grandes continuam astronomicamente reconhecíveis. */
|
||||
function mwDensity(l, b) {
|
||||
const dl = Math.min(l, 360 - l);
|
||||
const core = Math.exp(-((dl / 72) ** 2));
|
||||
const cloud = mwFbm(l, b);
|
||||
const fine = mwFineNoise(l, b);
|
||||
const warpedB = b - (cloud - 0.5) * (2.2 + 1.8 * core);
|
||||
const width = 3.7 + 5.8 * core + 1.3 * cloud;
|
||||
const plane = Math.exp(-0.5 * (warpedB / width) ** 2);
|
||||
|
||||
/* Luz integrada: halo largo, nuvens irregulares e grão fino de estrelas. */
|
||||
let density = plane * (0.20 + 1.45 * cloud ** 2.1);
|
||||
density += 0.12 * Math.exp(-0.5 * (warpedB / (width * 2.9)) ** 2) *
|
||||
(0.35 + 0.65 * cloud);
|
||||
density += plane * Math.max(0, fine - 0.46) ** 2 * 3.8;
|
||||
|
||||
/* Grandes nuvens estelares reais, exageradas como numa longa exposição. */
|
||||
density += mwCloud(l, b, 0, 0.0, 20, 11, 1.55); // bojo de Sagitário
|
||||
density += mwCloud(l, b, 25, 0.5, 10, 4, 0.62); // Scutum
|
||||
density += mwCloud(l, b, 330, -0.7, 11, 5, 0.52);
|
||||
density += mwCloud(l, b, 285, -0.4, 13, 5, 0.55); // Carina
|
||||
density += mwCloud(l, b, 80, 0.8, 12, 4, 0.38); // Cygnus
|
||||
|
||||
/* Grande Fenda: duas trilhas estreitas, deformadas e com força irregular. */
|
||||
const riftReach = 1 - Math.max(0, Math.min(1, (dl - 70) / 55));
|
||||
const lr = l * DEG;
|
||||
const riftA = 0.6 + 1.55 * Math.sin(lr * 2.1 + 0.5) + 0.55 * Math.sin(lr * 7.0);
|
||||
const riftB = -2.5 + 0.85 * Math.sin(lr * 1.4 + 2.2);
|
||||
const darkA = Math.exp(-0.5 * ((b - riftA) / (0.85 + 0.75 * fine)) ** 2) *
|
||||
(0.58 + 0.22 * fine);
|
||||
const darkB = Math.exp(-0.5 * ((b - riftB) / 1.10) ** 2) * 0.44;
|
||||
/* Poeira reduz contraste, mas nunca perfura a faixa luminosa. */
|
||||
density *= 1 - Math.min(0.38, (darkA + darkB) * riftReach);
|
||||
density += plane * (0.045 + 0.075 * fine);
|
||||
|
||||
/* Saco de Carvão junto ao Cruzeiro do Sul. */
|
||||
const coal = Math.exp(-0.5 * ((mwAngularDistance(l, 303) / 3.8) ** 2 +
|
||||
((b + 1.5) / 2.8) ** 2));
|
||||
density *= 1 - 0.38 * coal;
|
||||
|
||||
return Math.max(0, density * (0.36 + 0.64 * Math.exp(-((dl / 110) ** 2))));
|
||||
}
|
||||
|
||||
function mwWriteRgb(pixels, offset, l, b) {
|
||||
const dl = Math.min(l, 360 - l);
|
||||
const t = Math.min(1, dl / 150);
|
||||
const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
|
||||
let r, g, bl;
|
||||
if (t < 0.5) {
|
||||
r = 255 + (210 - 255) * u;
|
||||
g = 178 + (181 - 178) * u;
|
||||
bl = 112 + (246 - 112) * u;
|
||||
} else {
|
||||
r = 210 + (105 - 210) * u;
|
||||
g = 181 + (197 - 181) * u;
|
||||
bl = 246 + (255 - 246) * u;
|
||||
}
|
||||
/* Pequena separação cromática transversal: âmbar/magenta de um lado,
|
||||
azul-ciano do outro, como numa astrofoto com exposição puxada. */
|
||||
const tint = 0.5 + 0.5 * Math.sin(l * DEG * 1.7 + b * DEG * 8.0 + 1.1);
|
||||
const edge = Math.min(1, Math.abs(b) / 14);
|
||||
r += 24 * (1 - tint) * (0.35 + edge);
|
||||
g -= 12 * (1 - tint) * edge;
|
||||
bl += 18 * tint * (0.35 + edge);
|
||||
pixels[offset] = Math.max(0, Math.min(255, r));
|
||||
pixels[offset + 1] = Math.max(0, Math.min(255, g));
|
||||
pixels[offset + 2] = Math.max(0, Math.min(255, bl));
|
||||
}
|
||||
|
||||
function renderMilkyWay(W, H, horizonPx, lst, glareOn) {
|
||||
const rw = Math.max(1, Math.ceil(W * MW_RENDER_SCALE));
|
||||
const rh = Math.max(1, Math.ceil(horizonPx * MW_RENDER_SCALE));
|
||||
if (mwLayer.width !== rw || mwLayer.height !== rh) {
|
||||
mwLayer.width = rw;
|
||||
mwLayer.height = rh;
|
||||
}
|
||||
const mctx = mwLayer.getContext('2d');
|
||||
const image = mctx.createImageData(rw, rh);
|
||||
const pixels = image.data;
|
||||
const sl = Math.sin(obsLat * DEG), cl = Math.cos(obsLat * DEG);
|
||||
const lstRad = lst * DEG, cosLst = Math.cos(lstRad), sinLst = Math.sin(lstRad);
|
||||
|
||||
const azSin = new Float32Array(rw), azCos = new Float32Array(rw);
|
||||
for (let x = 0; x < rw; x++) {
|
||||
const az = (VIEW_AZ + ((x + 0.5) / rw - 0.5) * FOV_AZ) * DEG;
|
||||
azSin[x] = Math.sin(az);
|
||||
azCos[x] = Math.cos(az);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
for (let y = 0; y < rh; y++) {
|
||||
const screenY = (y + 0.5) / rh * horizonPx;
|
||||
const alt = ALT_TOP * (1 - screenY / horizonPx);
|
||||
const sa = Math.sin(alt * DEG), ca = Math.cos(alt * DEG);
|
||||
const ext = extinction(alt);
|
||||
for (let x = 0; x < rw; x++, offset += 4) {
|
||||
const north = ca * azCos[x], east = ca * azSin[x];
|
||||
const hourX = -north * sl + sa * cl;
|
||||
const ez = north * cl + sa * sl;
|
||||
const ex = cosLst * hourX - sinLst * east;
|
||||
const ey = sinLst * hourX + cosLst * east;
|
||||
|
||||
/* E2G = transposta de G2E. */
|
||||
const gx = G2E[0][0] * ex + G2E[1][0] * ey + G2E[2][0] * ez;
|
||||
const gy = G2E[0][1] * ex + G2E[1][1] * ey + G2E[2][1] * ez;
|
||||
const gz = G2E[0][2] * ex + G2E[1][2] * ey + G2E[2][2] * ez;
|
||||
const l = (Math.atan2(gy, gx) / DEG + 360) % 360;
|
||||
const b = Math.asin(Math.max(-1, Math.min(1, gz))) / DEG;
|
||||
if (Math.abs(b) > 35) continue;
|
||||
|
||||
const density = mwDensity(l, b);
|
||||
if (density < 0.012) continue;
|
||||
const screenX = (x + 0.5) / rw * W;
|
||||
const glare = glareFactor(screenX, screenY, W, H, glareOn);
|
||||
/* Curva de exposição comprime o núcleo sem apagar estruturas tênues. */
|
||||
const exposure = 1 - Math.exp(-density * 0.72);
|
||||
const alpha = Math.min(0.46, exposure * 0.42) * ext * glare;
|
||||
if (alpha < 0.002) continue;
|
||||
mwWriteRgb(pixels, offset, l, b);
|
||||
pixels[offset + 3] = alpha * 255;
|
||||
/* Vetores do viewport em LST=0. O passo de 30 s só aplica uma rotação e
|
||||
amostra a fotografia; trigonometria e efeitos de tela ficam em cache. */
|
||||
function buildMwGrid() {
|
||||
const W = canvas.width, H = canvas.height;
|
||||
if (!W || !H) { mwGrid = null; return; }
|
||||
const horizonPx = H * HORIZON_F;
|
||||
const gw = Math.ceil(W / MW_SAMPLE_SCALE);
|
||||
const gh = Math.ceil(horizonPx / MW_SAMPLE_SCALE);
|
||||
const n = gw * gh;
|
||||
const wx = new Float32Array(n), wy = new Float32Array(n), wz = new Float32Array(n);
|
||||
const ext = new Float32Array(n), glare = new Float32Array(n);
|
||||
const cl = Math.cos(obsLat * DEG), sl = Math.sin(obsLat * DEG);
|
||||
let i = 0;
|
||||
for (let gy = 0; gy < gh; gy++) {
|
||||
const sy = (gy + 0.5) * MW_SAMPLE_SCALE;
|
||||
const alt = (1 - sy / horizonPx) * ALT_TOP;
|
||||
const ca = Math.cos(alt * DEG), sa = Math.sin(alt * DEG);
|
||||
for (let gx = 0; gx < gw; gx++, i++) {
|
||||
const sx = (gx + 0.5) * MW_SAMPLE_SCALE;
|
||||
const az = (VIEW_AZ + (sx / W - 0.5) * FOV_AZ) * DEG;
|
||||
const north = ca * Math.cos(az), east = ca * Math.sin(az);
|
||||
wx[i] = -sl * north + cl * sa;
|
||||
wy[i] = -east;
|
||||
wz[i] = cl * north + sl * sa;
|
||||
ext[i] = extinction(alt);
|
||||
glare[i] = glareFactor(sx, sy, W, H, true);
|
||||
}
|
||||
}
|
||||
mctx.putImageData(image, 0, 0);
|
||||
mwCanvas.width = gw;
|
||||
mwCanvas.height = gh;
|
||||
mwGrid = { w: gw, h: gh, wx, wy, wz, ext, glare,
|
||||
img: mwCanvas.getContext('2d').createImageData(gw, gh) };
|
||||
}
|
||||
|
||||
function renderMilkyWay(lstDeg, glareOn) {
|
||||
if (!mwGrid || !mwTex.data) return false;
|
||||
const { wx, wy, wz, ext, glare, img } = mwGrid;
|
||||
const cL = Math.cos(lstDeg * DEG), sL = Math.sin(lstDeg * DEG);
|
||||
const m00 = E2G[0][0] * cL + E2G[0][1] * sL;
|
||||
const m01 = E2G[0][0] * sL - E2G[0][1] * cL;
|
||||
const m02 = E2G[0][2];
|
||||
const m10 = E2G[1][0] * cL + E2G[1][1] * sL;
|
||||
const m11 = E2G[1][0] * sL - E2G[1][1] * cL;
|
||||
const m12 = E2G[1][2];
|
||||
const m20 = E2G[2][0] * cL + E2G[2][1] * sL;
|
||||
const m21 = E2G[2][0] * sL - E2G[2][1] * cL;
|
||||
const m22 = E2G[2][2];
|
||||
const tw = mwTex.w, th = mwTex.h, tex = mwTex.data, px = img.data;
|
||||
const inv2pi = 1 / (2 * Math.PI), invpi = 1 / Math.PI;
|
||||
|
||||
for (let i = 0, j = 0; i < wx.length; i++, j += 4) {
|
||||
const gx = m00 * wx[i] + m01 * wy[i] + m02 * wz[i];
|
||||
const gy = m10 * wx[i] + m11 * wy[i] + m12 * wz[i];
|
||||
const gz = m20 * wx[i] + m21 * wy[i] + m22 * wz[i];
|
||||
const l = Math.atan2(gy, gx);
|
||||
const b = Math.asin(Math.max(-1, Math.min(1, gz)));
|
||||
const absB = Math.abs(b / DEG);
|
||||
if (absB >= 42) { px[j + 3] = 0; continue; }
|
||||
|
||||
let tx = ((Math.PI - l) * inv2pi * tw) | 0;
|
||||
let ty = ((Math.PI / 2 - b) * invpi * th) | 0;
|
||||
if (tx >= tw) tx = tw - 1;
|
||||
if (ty >= th) ty = th - 1;
|
||||
const k = (ty * tw + tx) * 4;
|
||||
const sr = tex[k], sg = tex[k + 1], sb = tex[k + 2];
|
||||
const lum = sr * 0.2126 + sg * 0.7152 + sb * 0.0722;
|
||||
let signal = (lum - MW_BLACK) / (255 - MW_BLACK);
|
||||
if (signal <= 0) { px[j + 3] = 0; continue; }
|
||||
signal = Math.pow(Math.min(1, signal), 0.72);
|
||||
|
||||
let mask = 1;
|
||||
if (absB > 18) {
|
||||
mask = (42 - absB) / 24;
|
||||
mask = mask * mask * (3 - 2 * mask);
|
||||
}
|
||||
const alpha = Math.min(1, signal * MW_EXPOSURE) * ext[i] *
|
||||
(glareOn ? glare[i] : 1) * mask;
|
||||
if (alpha < 0.004) { px[j + 3] = 0; continue; }
|
||||
|
||||
/* Revelação digital: preto profundo, saturação cromática e sombras frias. */
|
||||
let r = Math.max(0, sr - 34) * 1.58;
|
||||
let g = Math.max(0, sg - 34) * 1.58;
|
||||
let bl = Math.max(0, sb - 34) * 1.58;
|
||||
const outLum = r * 0.2126 + g * 0.7152 + bl * 0.0722;
|
||||
r = outLum + (r - outLum) * 1.48 + 10 * (1 - signal) + 16 * signal;
|
||||
g = outLum + (g - outLum) * 1.48;
|
||||
bl = outLum + (bl - outLum) * 1.48 + 24 * (1 - signal);
|
||||
px[j] = Math.max(0, Math.min(255, r));
|
||||
px[j + 1] = Math.max(0, Math.min(255, g));
|
||||
px[j + 2] = Math.max(0, Math.min(255, bl));
|
||||
px[j + 3] = alpha * 255;
|
||||
}
|
||||
mwCanvas.getContext('2d').putImageData(img, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Passo de projeção: recalcula posições de tela (a cada 30s o céu gira
|
||||
|
|
@ -1474,18 +1412,19 @@ function projectSky() {
|
|||
!document.body.classList.contains('chuva');
|
||||
twinkList = [];
|
||||
|
||||
renderMilkyWay(W, H, horizonPx, lst, glareOn);
|
||||
if (renderMilkyWay(lst, glareOn)) {
|
||||
bctx.imageSmoothingEnabled = true;
|
||||
bctx.imageSmoothingQuality = 'high';
|
||||
bctx.save();
|
||||
bctx.globalCompositeOperation = 'lighter';
|
||||
bctx.globalAlpha = 0.40;
|
||||
bctx.filter = `blur(${Math.max(10, H * 0.014)}px) saturate(180%)`;
|
||||
bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
|
||||
bctx.globalAlpha = 0.96;
|
||||
bctx.filter = 'contrast(122%) saturate(150%)';
|
||||
bctx.drawImage(mwLayer, 0, 0, W, horizonPx);
|
||||
bctx.globalAlpha = 0.22;
|
||||
bctx.filter = `blur(${Math.max(7, H * 0.009)}px) saturate(150%)`;
|
||||
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
|
||||
bctx.globalAlpha = 0.94;
|
||||
bctx.filter = 'contrast(116%) saturate(158%)';
|
||||
bctx.drawImage(mwCanvas, 0, 0, W, horizonPx);
|
||||
bctx.restore();
|
||||
}
|
||||
|
||||
for (let i = 0; i < STAR_DATA.length; i++) {
|
||||
const [ra, dec, mag, ci] = STAR_DATA[i];
|
||||
|
|
@ -1524,12 +1463,14 @@ function setObserverLocation(lat, lon) {
|
|||
if (lat === obsLat && lon === obsLon) return;
|
||||
obsLat = lat;
|
||||
obsLon = lon;
|
||||
buildMwGrid();
|
||||
projectSky();
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
buildMwGrid();
|
||||
projectSky();
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
plugin/contents/ui/milkyway.jpg
Normal file
BIN
plugin/contents/ui/milkyway.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 MiB |
86
tools/build_skymap.py
Normal file
86
tools/build_skymap.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Gera a textura galáctica de alta resolução usada pelo wallpaper.
|
||||
|
||||
Fonte: "Gaia's sky in colour" (ESA/Gaia/DPAC, CC BY-SA 3.0 IGO),
|
||||
8000x4000 em projeção Hammer e coordenadas galácticas. A saída é plate
|
||||
carrée, adequada para amostragem direta por longitude/latitude no canvas.
|
||||
|
||||
O processamento é feito em blocos para não manter várias grades 6K inteiras
|
||||
na memória. Requer Pillow e numpy.
|
||||
"""
|
||||
|
||||
import io
|
||||
import math
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
URL = (
|
||||
"https://upload.wikimedia.org/wikipedia/commons/e/ea/"
|
||||
"Gaia%E2%80%99s_sky_in_colour_ESA393127.png"
|
||||
)
|
||||
CACHE = Path.home() / ".cache/skeledance/gaia_allsky.png"
|
||||
OUT = Path(__file__).resolve().parent.parent / "plugin/contents/ui/milkyway.jpg"
|
||||
SIZE = (6144, 3072)
|
||||
BLOCK_ROWS = 96
|
||||
QUALITY = 92
|
||||
|
||||
|
||||
def fetch() -> bytes:
|
||||
if CACHE.exists():
|
||||
print(f"Usando cache {CACHE}")
|
||||
return CACHE.read_bytes()
|
||||
print(f"Baixando {URL} ...")
|
||||
req = urllib.request.Request(URL, headers={"User-Agent": "skeledance-build/2.0"})
|
||||
raw = urllib.request.urlopen(req, timeout=180).read()
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE.write_bytes(raw)
|
||||
print(f" {len(raw) / 1e6:.1f} MB (cacheado)")
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> None:
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
src = np.asarray(Image.open(io.BytesIO(fetch())).convert("RGB"))
|
||||
sh, sw = src.shape[:2]
|
||||
tw, th = SIZE
|
||||
out = np.empty((th, tw, 3), dtype=np.uint8)
|
||||
|
||||
# Longitude cresce para a esquerda para coincidir com a imagem Gaia.
|
||||
lon = np.deg2rad(180 - (np.arange(tw, dtype=np.float64) + 0.5) * 360 / tw)
|
||||
|
||||
for y_start in range(0, th, BLOCK_ROWS):
|
||||
y_stop = min(th, y_start + BLOCK_ROWS)
|
||||
lat = np.deg2rad(
|
||||
90 - (np.arange(y_start, y_stop, dtype=np.float64) + 0.5) * 180 / th
|
||||
)
|
||||
lon_grid, lat_grid = np.meshgrid(lon, lat)
|
||||
|
||||
denom = np.sqrt(1 + np.cos(lat_grid) * np.cos(lon_grid / 2))
|
||||
hx = 2 * math.sqrt(2) * np.cos(lat_grid) * np.sin(lon_grid / 2) / denom
|
||||
hy = math.sqrt(2) * np.sin(lat_grid) / denom
|
||||
sx = (1 - hx / (2 * math.sqrt(2))) * sw / 2
|
||||
sy = (1 - hy / math.sqrt(2)) * sh / 2
|
||||
|
||||
# Amostragem bilinear preserva filamentos sem serrilhar a reprojeção.
|
||||
x0 = np.clip(np.floor(sx).astype(np.int32), 0, sw - 1)
|
||||
y0 = np.clip(np.floor(sy).astype(np.int32), 0, sh - 1)
|
||||
x1 = np.minimum(x0 + 1, sw - 1)
|
||||
y1 = np.minimum(y0 + 1, sh - 1)
|
||||
fx = (sx - x0)[..., None]
|
||||
fy = (sy - y0)[..., None]
|
||||
top = src[y0, x0] * (1 - fx) + src[y0, x1] * fx
|
||||
bottom = src[y1, x0] * (1 - fx) + src[y1, x1] * fx
|
||||
out[y_start:y_stop] = np.clip(top * (1 - fy) + bottom * fy, 0, 255)
|
||||
print(f"\rReprojetando: {y_stop * 100 // th:3d}%", end="", flush=True)
|
||||
|
||||
print()
|
||||
Image.fromarray(out).save(OUT, "JPEG", quality=QUALITY, optimize=True)
|
||||
print(f"{OUT} ({OUT.stat().st_size / 1024 / 1024:.1f} MB, {tw}x{th})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue