我會抬起前爪
四圍是無垠的暗。
羽落睜開眼,又緩緩闔上了。那是一種沉重的、浸透了苦澀的閉合。它知道這片空間。腳下是無邊際的黑色平面,冰涼,死寂,連沉默都是凝固的;頭頂是同樣徹底的漆黑,彷彿那裏從來不存在"光"這個概念,光只是一個傳說,一個謊言。
它靜默了很久。久到那片黑暗幾乎開始變得熟悉。
然後,它坐起身來,抬起一隻前爪。
爪心攏着一團微光。那光很輕,很薄,像是從骨髓最深處滲出的最後一滴溫熱——稀薄得彷彿呼出一口氣就會散盡。它低下頭,將那團光貼在自己的額前,靜靜停留了一瞬。白色鱗片上漫着細碎的反光,藍色的鬃毛垂落下來,像夜裏最後一縷還未熄滅的色彩。
像是終於做了什麼決定。
它鬆開了爪。
光沒有墜落。它緩緩向上升起,像一粒拒絕被黑暗吞沒的種子,掙脫了什麼無形之物的拖拽。在上升中,它不斷膨脹,越來越亮——十米、百米、千米——黑暗被一寸寸推開,像受了灼傷,向四面退縮、蜷曲。
然後,它炸開了。
漫天光點如同一場無聲的暴雨,倒潑在那片曾經絕對的虛空之上。羽落在這一刻躍起——不是出於命令,不是出於意志,而是出於某種比意志更古老的東西——隨那片光芒起舞。
它的前爪劃過頭頂的虛空,光點便沿着那軌跡聚攏、流散,像是懂得某種無詞的召喚。每一步踏落,腳下便綻開一圈柔和的光暈,光暈之中有綠意破土而出——一株株青草,綠得像一個宣言,綠得刺眼,彷彿顏色這件事本身就是一種反抗。它們隨着某種聽不見的律動輕輕擺盪,向四面漫開,像水波,像呼吸。
爪尖劃過那片黑色平面時,裂縫應聲而生,水從其中奔湧而出,清冽,不回頭。
綠色將整個世界漫了過去。
羽落這才振翅而起。雙翼帶起的氣流成了風,它滑翔過的地方,天空漸漸褪去深暗,露出蔚藍,像一塊被拭去塵埃的舊布,顏色慢慢透出來。一股火焰從它喉間噴出,蒸騰的水汽升入高空,凝成雲;多餘的那些火焰不肯散去,高高懸在天邊,成了太陽。
最後,它俯衝而下,以全部的重量撞向大地。大地在那一擊中起伏、隆起,生出山巒,像是終於有了脊樑。
羽落站在那片剛剛誕生的草原上。風吹過來,拂動它背上藍色的鬃毛。它抬眼望向遠方的山峯,目光裏有一種說不清道不明的情緒——像是在努力記住這個世界,又像是在努力忘記它。
但在它心底,有一點微弱的欣喜,悄悄地,亮了一下。
羽落沿着河流緩緩前行。新生的水還帶着最初的清冽。它低下頭,鼻尖輕輕觸碰水面。水波盪開,倒影裏的龍也隨之破碎。
它愣了一下,然後又伸出爪尖,在水面輕輕劃過,一圈又一圈漣漪擴散出去,那些細碎的波紋讓它莫名地看了很久。
它趴伏在草地上,風從遠方吹來,掠過一望無際的綠色海洋,草浪起伏,沙沙作響。
羽落閉上眼,任由風鑽進鬃毛,穿過翼膜,拂過鱗片之間細小的縫隙。世界沒有說話,可它覺得自己似乎聽見了什麼,像是一聲極輕極輕的呼吸。
有一天,它飛上了最高的山峯,那裏離雲很近,離太陽也很近。
羽落坐在懸崖邊緣,望着雲海緩慢翻湧。雲影在大地上緩緩移動,河流像銀色絲線一般蜿蜒向遠方。它忽然意識到,即使自己什麼也不做,這個世界也會繼續運轉。草會自顧自地生長,風會無休止地吹拂,河流總能奔向遠方。
那一刻,它心底生出一種奇異的輕鬆。就像一個疲憊的旅人,終於目送自己的孩子獨自走向遠方。
不知道過了多久。
黑色的狂潮來了。從天上,從地下,從每一道縫隙、每一寸虛空湧出,湧出,湧出。它吞噬顏色,吞噬綠意,吞噬那條奔湧的河流與高懸的太陽。
那一刻,羽落眼中有什麼東西點燃了。
它衝入那片狂潮之中。火焰、利齒、雙翼、尖爪、尾梢——它將自己全部化作武器,打得那片黑暗一度向後退縮,露出殘破的蔚藍。
但黑暗沒有停止。
它的動作漸漸遲緩。每一次揮爪、每一次振翅,都像是在泥沼中掙扎,像是空氣本身變得黏稠,像是重力背叛了它。終於,在一次力竭的喘息之後,黑色的潮水漫過了它的脊背,漫過了它背上那抹藍,漫過了它身體裏最後一絲髮着光的東西。
一切都沉了下去。
羽落在一片黑色空間裏醒來。
腳下是無邊際的平面,冰涼,死寂。頭頂是完全的漆黑。它抬頭四顧,然後以一種沉重的、浸透了苦澀的動作,重新閉上了眼。
很久,
很久之後,它坐了起來。
抬起一隻前爪。爪心攏着一團微光。
---- the end ......? ----
靈感來源:
遊戲:《Neva》
音樂:Roger Subirana - Between Worlds
自己爪寫原稿,隨後使用deepseek4.0(快速模式)、claude sonnet 4.6 Medium、gpt5.5美化。
根據自身真實經歷藝術加工。
最后修改: 羽落 (2026-06-04 18:00:13)
有 1 位朋友喜欢这篇文章:龍爪翻書
參加了一個在線講座。
維也納大學的伊斯蘭藝術史權威薩拉·庫恩(Sara Kuehn)教授將介紹她關於龍在伊斯蘭藝術中複雜作用的研究。通過分析天文現象——特別是月球交點——與視覺文化的交匯,庫恩揭示了龍象如何演變成魔法保護與宇宙平衡的強大象徵。

總結如下:
今天提到“龍”,人們通常會想到神話、奇幻文學,或者電子遊戲中的怪獸。但在中世紀的伊斯蘭世界,龍並不僅僅屬於幻想。它曾經出現在天文學、占星術、醫學手稿、護符藝術與王權象徵之中。德國學者 Sara Kuehn 在關於中世紀伊斯蘭“龍”觀念的研究中指出,這種龍是一個橫跨印度、伊朗、希臘與伊斯蘭文明的“跨文化宇宙符號”。
“天龍食日”,龍爲什麼會吞掉太陽
古代文明普遍存在“食日”的觀念。當太陽或月亮突然變暗,人們自然會尋找解釋。
中國有天狗食日,北歐神話中有巨狼吞噬太陽。在印度神話中,造成日食月食的則是 Rāhu(羅睺)與Ketu(計都)。它們原本是一個偷喝神酒的不死怪物,被神明斬成兩段,頭部成爲羅睺,尾部成爲計都。它們不斷追逐太陽與月亮,並在追上時將其吞噬,於是形成日食與月食。
神話故事後來逐漸與天文學結合。
古印度的天文學家已經知道日食與月食並不是隨機發生的,它們總出現在兩個特殊位置——月球軌道與太陽運行路徑(黃道)相交的地方。現代天文學把這兩個位置稱爲 升交點 和 降交點。而在印度與後來的伊斯蘭占星傳統中,它們則被稱爲 龍頭 和 龍尾。
從印度到伊朗,再進入伊斯蘭世界
隨着印度與波斯天文學傳入阿拔斯王朝,中世紀伊斯蘭學者繼承並系統化了這一概念。
在中古波斯傳統中(公元 3-7 世紀),日蝕龍被稱爲 Gōchihr,《Bundahišn(創世書)》將其描述爲與黑暗、食相和宇宙危險相關的龍形存在。Gōchihr 後來被伊斯蘭學者稱作:al-jawzahar,或直接稱作 al-tinnīn(龍)。它逐漸與月交點體系融合。
阿拉伯語中,“龍之首”稱爲 raʾs al-tinnīn,“龍之尾”稱爲 dhanab al-tinnīn,它們合稱爲“二交點”al-ʿuqdatāni。在一些波斯與阿拉伯手稿中會被畫成交纏的雙身蛇或龍形生物。
龍不僅出現於天文學/占星術,也出現在鍊金術、醫藥學、建築領域
在中世紀伊斯蘭鍊金術中,龍也頻繁出現。
它通常象徵物質循環,腐化與再生,宇宙變化,元素轉化。其中最著名的形象是 Ouroboros(銜尾蛇),即一條吞食自己尾巴的龍。這一圖像象徵世界循環,生死輪迴,永恆運動,混沌與秩序之間的邊界。在伊斯蘭藝術裏,銜尾蛇常被放置於門、窗、拱券等“邊界位置”,作爲一種界限標誌、辟邪物。也就是說,龍既危險,又能保護人類免受危險。
龍同樣出現在藥書中。
中世紀伊斯蘭世界還有一種著名藥物 theriac(特里亞克),阿拉伯語 diryāq。它是一種複雜配方的解毒藥,據說能夠抵禦蛇毒、瘟疫、毒藥。《Kitāb al-diryāq(解藥之書)》中都繪有蛇/龍圖像,龍和蛇既象徵毒性又象徵解毒。這種觀念來自古代地中海醫學與伊朗傳統的融合。
龍也經常出現在建築中。
比如城門,拱券,門楣,宮殿入口。尤其常以成對蛇龍、交纏龍、翼龍的形式出現。這些龍並不只是裝飾,它們承擔的是“邊界守護”的功能。因爲龍本身就是天空與地下之間的生物,水與火之間的生物,生與死之間的生物。它能穿越不同世界,因此最適合守護“界限”。
科學的發展與龍的淡出
今天看來,把龍與天文學、醫學等聯繫在一起,似乎完全不科學。但這恰恰提醒我們現代科學與神祕學的嚴格邊界其實是近代才形成的。
中世紀知識框架的運作方式完全不同。知識通常基於繼承的權威、目擊者證詞、古代文本、宗教傳統以及代代相傳的積累報告。如果值得信賴的旅行者、商人、學者、水手或宗教權威描述了不尋常的生物,人們就會認爲它們的存在是合理的,即使他們自己從未遇到過它們。這並不意味着中世紀的人們是非理性和天真的,相反,他們對知識如何運作有不同的理解。人們相信世界是廣闊的、神祕的,但人們所知的卻只有一部分。偏遠的沙漠、山脈、島嶼、廢墟和海洋被想象爲非凡生物居住的地方。陌生的生命形式,稀有並不意味着不可能。一種生物可能非常罕見,但仍然完全真實。在這種世界觀中,龍經常被理解爲居住在世界偏遠地區的巨大蛇形生物。阿拉伯語術語本身就反映了這種聯繫,所以我們有阿拉伯語單詞tinnīn,指的是一種巨大的蛇或龍,具有破壞力、毒息和可怕的宇宙力量。因此,龍經常出現在動物學、地理學、宇宙論和自然歷史的討論中。
天文學 - 月交點與食相
占星術 - 命運與宇宙影響
醫學 - 毒與解毒
鍊金術 - 轉化與循環
建築 - 守護與辟邪
宗教 - 混沌與秩序邊界
之後,隨着科學的興起,人們對世界的解釋方式發生了巨大變化,“龍”不再屬於自然知識的一部分,逐漸退回到神話與幻想文學之中。
有 4 位朋友喜欢这篇文章:SmallDragon, 破灭之月, 龍爪翻書, Forgotten
雖然標註了不能使用魔法,但是看到標題還是第一反應想到了《魔龍之書》裏的《一抹藍》
(劇透內容)故事裏的龍們以消耗記憶(信息)轉化能量的方式維持自身存在,在結尾被引申到一種治療抑鬱症的可能(w感謝作者最後保留了倫理方面的想象空間)。
常見的,抑鬱容易沉溺過去,焦慮容易放大未來的潛在威脅,而龍的壽命與認知尺度如果遠大於人類,物種間的“
距離感”可能會很明顯。這種距離一方面可能讓龍不容易被來訪者的情緒捲入移情,但另一方面也可能因爲缺乏類似的生命經驗,會有不少情緒體驗難以感同身受,不過如果立志要做這個職業,我想應該有足夠長的時間來讓這個問題不成問題。
另外允許rua龍的毛毛可能也是一種情緒撫慰的方式,如果龍諮詢師倫理手冊上面沒禁止的話(
除了語言之外,還需要別的技能。
比如,深入理解人類社會生活的各個階段。當來訪者訴說自己童年親子關係的時候,你需要能理解他在說什麼(畢竟你從蛋裏孵出來的,感同身受有困難)並做出恰當回應。
再比如察言觀色的能力,覺察細微的表情、語氣變化,判斷其中包含什麼樣的情緒,及時捕捉到這些信息並反饋給來訪者。龍不一定善於觀察人類表情,但或許善於觀察肢體語言。
還有共情能力,跨物種共情能力我不知道有多困難,不過至少可以通過觀察和學習來掌握模擬替代共情能力的技能,給出恰當的反饋。
這是一篇文學理論 / 文化研究論文,以《重生之翼:太陽朋克龍主題選集》這本書中的兩篇短文爲素材(《羣陽引航之翼》《龍的託誓》)來分析龍在構建太陽朋克故事時所扮演角色的後人類(posthuman)角色。
節選一些比較有意思的部分。
後人類主義是一種對人類中心主義的反思,認爲人類不是世界唯一重要的存在。
龍作爲後人類(The Dragon as Posthuman)
龍既有動物的野性,又具有智慧,同時帶有神性。因此很適合作爲“後人類主體”。
大意是說,龍常常被塑造成一種介於“自然”與“文明”之間的存在。在各類作品中,龍很少主動擴張或掠奪,相反,真正大規模破壞環境、發動征服的,往往是人類自身。龍逐漸成爲一種“自然力量”的象徵。預示着世界並不只屬於人類,其他生命同樣具有自身的價值與主體性。
這也正是後人類主義的重要思想之一,對於人類中心主義的反思,人類只是世界中的一部分,世界並非由人類單獨構成,而是由無數生命、物質與力量共同編織而成。
最后修改: shiningdracon (2026-05-21 19:52:49)
有 3 位朋友喜欢这篇文章:Forgotten, NancalaStarry, 龍爪翻書
像素畫轉h5 canvas網頁,最近在摸索怎麼讓ai給我把像素畫變成gif,順便就vibe code了一個這個。注意:超過128*128像素轉換可能會卡頓,對於海量顏色的ai像素畫原圖轉換的網頁大小會很大(300kb左右),46種顏色、大概256*170的像素畫也要70kb,還在摸索更加好的方式。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>像素画 → Canvas 代码转换器</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Noto+Sans+SC:wght@300;500;700&display=swap');
:root {
--bg: #0e0e12;
--surface: #16161e;
--border: #2a2a38;
--accent: #e8ff5a;
--accent2: #ff5a8a;
--accent3: #5af0ff;
--text: #e8e8f0;
--muted: #6a6a88;
--pixel: 4px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', sans-serif;
font-weight: 300;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px);
pointer-events: none;
z-index: 999;
}
header {
padding: 2rem 2.5rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 1.2rem;
position: relative;
}
header::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 120px;
height: 2px;
background: var(--accent);
}
.logo {
font-family: 'Space Mono', monospace;
font-size: 1.4rem;
font-weight: 700;
color: var(--accent);
letter-spacing: -1px;
}
.logo span {
color: var(--accent2);
}
.subtitle {
font-size: 0.78rem;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
gap: 0;
height: calc(100vh - 73px);
min-height: 0;
overflow: hidden;
}
/* ---- Drop zone ---- */
.drop-zone {
grid-column: 1;
grid-row: 1 / 3;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.2rem;
padding: 2rem;
cursor: pointer;
transition: background 0.2s;
position: relative;
overflow: hidden;
}
.drop-zone:hover {
background: rgba(232, 255, 90, 0.03);
}
.drop-zone.drag-over {
background: rgba(232, 255, 90, 0.07);
}
.drop-zone.has-image {
justify-content: flex-start;
padding-top: 1.5rem;
}
.drop-icon {
width: 64px;
height: 64px;
border: 2px solid var(--border);
display: grid;
place-items: center;
color: var(--muted);
position: relative;
}
.drop-icon::before,
.drop-icon::after {
content: '';
position: absolute;
background: var(--accent);
}
.drop-icon::before {
width: 2px;
height: 28px;
}
.drop-icon::after {
width: 28px;
height: 2px;
}
.drop-text {
text-align: center;
font-size: 0.82rem;
color: var(--muted);
line-height: 1.7;
}
.drop-text strong {
color: var(--text);
font-weight: 500;
display: block;
margin-bottom: 4px;
}
#file-input {
display: none;
}
.pixel-preview {
image-rendering: pixelated;
image-rendering: crisp-edges;
max-width: 100%;
max-height: calc(100% - 120px);
border: 1px solid var(--border);
display: none;
}
.preview-info {
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
color: var(--accent3);
text-align: center;
display: none;
}
.btn-convert {
display: none;
background: var(--accent);
color: #000;
border: none;
font-family: 'Space Mono', monospace;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 1px;
padding: 0.6rem 1.8rem;
cursor: pointer;
text-transform: uppercase;
transition: all 0.15s;
position: relative;
}
.btn-convert::after {
content: '';
position: absolute;
bottom: -3px;
right: -3px;
width: 100%;
height: 100%;
border: 2px solid var(--accent);
transition: all 0.15s;
}
.btn-convert:hover {
transform: translate(-2px, -2px);
}
.btn-convert:hover::after {
bottom: -5px;
right: -5px;
}
.btn-convert:active {
transform: translate(0, 0);
}
.error-msg {
color: var(--accent2);
font-size: 0.78rem;
font-family: 'Space Mono', monospace;
text-align: center;
display: none;
}
/* ---- Output panel ---- */
.output-panel {
grid-column: 2;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
min-width: 0;
/* 关键修复:防止超长单行文本把面板无限撑宽 */
min-height: 0;
/* 关键修复:防止纵向无限撑高 */
height: 100%;
/* 填满父容器的高度 */
}
.panel-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 0.65rem;
padding: 0.3rem 0.8rem;
cursor: pointer;
letter-spacing: 1px;
text-transform: uppercase;
transition: all 0.15s;
}
.btn-sm:hover {
border-color: var(--accent3);
color: var(--accent3);
}
.btn-sm.active {
border-color: var(--accent);
color: var(--accent);
}
.code-area {
flex: 1;
overflow: auto;
/* 此时生效,会规规矩矩在内部出现滚动条 */
padding: 1.2rem 1.5rem;
font-family: 'Space Mono', monospace;
font-size: 0.68rem;
line-height: 1.8;
color: #c8c8e0;
background: var(--surface);
white-space: pre-wrap;
/* 替换原来的 pre:允许代码在超长时自动换行,防止横向无限溢出 */
word-break: break-all;
/* 强制长字符串/数组在边界断行,彻底杜绝撑开容器 */
tab-size: 2;
}
.code-area:empty::before {
content: '// 上传像素画后点击"转换"按钮\n// 这里将生成 canvas 绘制代码';
color: var(--muted);
font-style: italic;
}
/* syntax highlight colours */
.kw {
color: #ff5a8a;
}
.fn {
color: #e8ff5a;
}
.str {
color: #5af0ff;
}
.num {
color: #ffb85a;
}
.cm {
color: #6a6a88;
font-style: italic;
}
.status-bar {
padding: 0.4rem 1.5rem;
border-top: 1px solid var(--border);
background: var(--bg);
font-family: 'Space Mono', monospace;
font-size: 0.62rem;
color: var(--muted);
display: flex;
gap: 2rem;
}
.status-bar span {
color: var(--accent3);
}
/* copy toast */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--accent);
color: #000;
font-family: 'Space Mono', monospace;
font-size: 0.72rem;
font-weight: 700;
padding: 0.5rem 1.2rem;
transform: translateY(80px);
opacity: 0;
transition: all 0.3s cubic-bezier(.34, 1.56, .64, 1);
pointer-events: none;
z-index: 1000;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
/* pixel grid bg decoration */
.pixel-grid-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.3;
pointer-events: none;
}
</style>
</head>
<body>
<header>
<div class="logo">PIXEL<span>→</span>CVS</div>
<div class="subtitle">像素画 · Canvas 代码生成器</div>
</header>
<main>
<!-- Left: upload -->
<div class="drop-zone" id="dropZone">
<div class="pixel-grid-bg"></div>
<div class="drop-icon" id="dropIcon"></div>
<div class="drop-text" id="dropText">
<strong>拖拽或点击上传像素画</strong>
支持 PNG / GIF / BMP · 8×8 ~ 512×512 像素
</div>
<img class="pixel-preview" id="preview" alt="preview">
<div class="preview-info" id="previewInfo"></div>
<div class="error-msg" id="errorMsg"></div>
<button class="btn-convert" id="btnConvert" onclick="convert()">生成 Canvas 代码</button>
<input type="file" id="file-input" accept="image/png,image/gif,image/bmp,image/jpeg,image/webp">
</div>
<!-- Right: output -->
<div class="output-panel">
<div class="panel-header">
<div class="panel-title">生成代码</div>
<div class="panel-actions">
<button class="btn-sm active" id="btnModeInline" onclick="setMode('inline')">内联颜色</button>
<button class="btn-sm" id="btnModePalette" onclick="setMode('palette')">调色板</button>
<button class="btn-sm" id="btnCopy" onclick="copyCode()">复制</button>
<button class="btn-sm" id="btnDownload" onclick="downloadCode()">下载</button>
</div>
</div>
<div class="code-area" id="codeArea"></div>
<div class="status-bar" id="statusBar">
<span id="statSize">—</span> 像素
· 代码 <span id="statLines">—</span> 行
· <span id="statColors">—</span> 种颜色
</div>
</div>
</main>
<div class="toast" id="toast">已复制到剪贴板</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('file-input');
const preview = document.getElementById('preview');
const previewInfo = document.getElementById('previewInfo');
const btnConvert = document.getElementById('btnConvert');
const errorMsg = document.getElementById('errorMsg');
const codeArea = document.getElementById('codeArea');
const dropIcon = document.getElementById('dropIcon');
const dropText = document.getElementById('dropText');
let currentMode = 'inline';
let currentCode = '';
let imgData = null; // {width, height, data: Uint8ClampedArray}
// ---- Drag & Drop ----
dropZone.addEventListener('click', e => {
if (e.target === btnConvert) return;
fileInput.click();
});
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f) loadFile(f);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) loadFile(fileInput.files[0]);
});
function loadFile(file) {
showError('');
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const w = img.naturalWidth, h = img.naturalHeight;
if (w < 8 || h < 8 || w > 512 || h > 512) {
showError(`尺寸 ${w}×${h} 不在支持范围(8×8 ~ 512×512)内`);
URL.revokeObjectURL(url);
return;
}
// Read pixel data
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
imgData = { width: w, height: h, data: ctx.getImageData(0, 0, w, h).data };
URL.revokeObjectURL(url);
// Show preview
preview.src = canvas.toDataURL();
preview.style.display = 'block';
previewInfo.textContent = `${w} × ${h} px`;
previewInfo.style.display = 'block';
btnConvert.style.display = 'inline-block';
dropIcon.style.display = 'none';
dropText.style.display = 'none';
dropZone.classList.add('has-image');
// Auto-convert
convert();
};
img.onerror = () => showError('图片加载失败,请重试');
img.src = url;
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = msg ? 'block' : 'none';
}
function setMode(m) {
currentMode = m;
document.getElementById('btnModeInline').classList.toggle('active', m === 'inline');
document.getElementById('btnModePalette').classList.toggle('active', m === 'palette');
if (imgData) convert();
}
// ---- Core conversion ----
function convert() {
if (!imgData) return;
const { width: W, height: H, data } = imgData;
// Build pixel map: index -> rgba string
const pixels = [];
for (let i = 0; i < W * H; i++) {
const r = data[i * 4], g = data[i * 4 + 1], b = data[i * 4 + 2], a = data[i * 4 + 3];
pixels.push(a < 16 ? null : rgbaStr(r, g, b, a));
}
const uniqueColors = [...new Set(pixels.filter(Boolean))];
if (currentMode === 'palette') {
currentCode = generatePaletteCode(W, H, pixels, uniqueColors);
} else {
currentCode = generateInlineCode(W, H, pixels);
}
renderCode(currentCode);
updateStats(W, H, uniqueColors.length);
}
function rgbaStr(r, g, b, a) {
if (a === 255) {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
return `rgba(${r},${g},${b},${(a / 255).toFixed(2)})`;
}
// --- Mode 1: inline (run-length compressed rows) ---
function generateInlineCode(W, H, pixels) {
const scale = computeScale(W, H);
const rows = [];
for (let y = 0; y < H; y++) {
// RLE per row
const row = [];
let i = y * W;
while (i < y * W + W) {
const col = pixels[i];
let run = 1;
while (i + run < y * W + W && pixels[i + run] === col) run++;
row.push([col, run]);
i += run;
}
rows.push(row);
}
// Serialise compactly
// Format each row as array of [color, count] — but skip fully-null rows
const rowStrs = rows.map((row, y) => {
if (row.every(([c]) => c === null)) return null;
const segs = row.map(([c, n]) => c === null ? `[0,${n}]` : (n === 1 ? `"${c}"` : `["${c}",${n}]`));
return ` /* y=${y} */ [${segs.join(',')}]`;
});
const rowsCode = rowStrs
.map((r, y) => r === null ? ` null /* y=${y} transparent */` : r)
.join(',\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>pixel art</title>
<style>body{margin:0;background:#111;display:flex;align-items:center;justify-content:center;min-height:100vh}</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ${W}x${H} pixel art scale:${scale}x
const W=${W}, H=${H}, S=${scale};
const rows=[
${rowsCode}
];
const c=document.getElementById('c');
c.width=W*S; c.height=H*S;
const ctx=c.getContext('2d');
rows.forEach((row,y)=>{
if(!row)return;
let x=0;
row.forEach(seg=>{
if(Array.isArray(seg)){
const[col,n]=seg;
if(typeof col==='string'){ctx.fillStyle=col;ctx.fillRect(x*S,y*S,n*S,S);}
x+=n;
} else {
ctx.fillStyle=seg;ctx.fillRect(x*S,y*S,S,S);x++;
}
});
});
<\/script>
</body>
</html>`;
}
// --- Mode 2: palette + index grid ---
function generatePaletteCode(W, H, pixels, palette) {
const idx = {};
palette.forEach((c, i) => idx[c] = i);
// Build index rows, compressing transparent as -1
const grid = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
const c = pixels[y * W + x];
row.push(c === null ? -1 : idx[c]);
}
// RLE
const rle = [];
let i = 0;
while (i < row.length) {
let run = 1;
while (i + run < row.length && row[i + run] === row[i]) run++;
rle.push(run === 1 ? row[i] : [row[i], run]);
i += run;
}
grid.push(rle);
}
const scale = computeScale(W, H);
const paletteStr = palette.map(c => `"${c}"`).join(',');
const gridStr = grid.map((row, y) =>
` /* y=${y} */ [${row.map(s => Array.isArray(s) ? `[${s[0]},${s[1]}]` : s).join(',')}]`
).join(',\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>pixel art</title>
<style>body{margin:0;background:#111;display:flex;align-items:center;justify-content:center;min-height:100vh}</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ${W}x${H} pixel art ${palette.length} colors scale:${scale}x
const W=${W},H=${H},S=${scale};
const P=[${paletteStr}]; // palette
const G=[
${gridStr}
];
const c=document.getElementById('c');
c.width=W*S;c.height=H*S;
const ctx=c.getContext('2d');
G.forEach((row,y)=>{
let x=0;
row.forEach(seg=>{
const[ci,n]=Array.isArray(seg)?seg:[seg,1];
if(ci>=0){ctx.fillStyle=P[ci];ctx.fillRect(x*S,y*S,n*S,S);}
x+=n;
});
});
<\/script>
</body>
</html>`;
}
function computeScale(w, h) {
// Target canvas around 400–600px, never go below 1
const target = 480;
const s = Math.max(1, Math.min(32, Math.floor(target / Math.max(w, h))));
return s;
}
// ---- Render with naive syntax highlight ----
function renderCode(code) {
const escaped = code
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// Very lightweight highlight
const hl = escaped
.replace(/(\/\/.+)/g, '<span class="cm">$1</span>')
.replace(/\b(const|let|var|function|return|if|else|forEach|new|document|null|true|false)\b/g, '<span class="kw">$1</span>')
.replace(/\b(getElementById|getContext|fillRect|fillStyle|width|height)\b/g, '<span class="fn">$1</span>')
.replace(/("[^&]*"|'[^&#]*')/g, '<span class="str">$1</span>')
.replace(/\b(\d+)\b/g, '<span class="num">$1</span>');
codeArea.innerHTML = hl;
}
function updateStats(w, h, colors) {
document.getElementById('statSize').textContent = `${w}×${h}`;
const lines = currentCode.split('\n').length;
document.getElementById('statLines').textContent = lines;
document.getElementById('statColors').textContent = colors;
}
function copyCode() {
if (!currentCode) return;
navigator.clipboard.writeText(currentCode).then(() => {
const t = document.getElementById('toast');
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
});
}
function downloadCode() {
if (!currentCode) return;
const a = document.createElement('a');
a.href = 'data:text/html;charset=utf-8,' + encodeURIComponent(currentCode);
a.download = 'pixel-art.html';
a.click();
}
</script>
</body>
</html>
有 2 位朋友喜欢这篇文章:龍爪翻書, NancalaStarry
像素畫量化工具,可以用於將ai生成海量顏色的像素畫減少顏色種類,cluade出品,搭配https://yinglong.org/forum/viewtopic.php?id=5052食用更佳
已更新v2,添加雙邊濾波預處理和SLIC超像素選項以及現在指向調色盤時處理後的圖像會高亮對應顏色。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>像素畫調色盤精簡工具</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Noto+Sans+SC:wght@400;500;700&display=swap');
:root {
--bg: #0e0e12;
--surface: #16161e;
--surface2: #1e1e2a;
--border: #2a2a38;
--accent: #c8ff57;
--accent2: #57c8ff;
--text: #e8e8f0;
--muted: #7878a0;
--pixel-size: 2px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.03) 2px, rgba(0, 0, 0, 0.03) 4px);
}
header {
border-bottom: 1px solid var(--border);
padding: 1.5rem 2rem;
display: flex;
align-items: baseline;
gap: 1.5rem;
}
.logo {
font-family: 'Space Mono', monospace;
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.tagline {
font-size: 0.8rem;
color: var(--muted);
letter-spacing: 0.05em;
}
.main {
display: grid;
grid-template-columns: 340px 1fr;
height: calc(100vh - 65px);
}
.sidebar {
border-right: 1px solid var(--border);
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.sidebar>* {
flex-shrink: 0;
/* 強制所有側邊欄子元素(面板、按鈕、統計)不壓縮自身高度 */
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: visible;
/* 改爲 visible,防止部分小組件陰影或邊緣被切掉 */
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
padding: 0.75rem 1rem;
font-family: 'Space Mono', monospace;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
border-bottom: 1px solid var(--border);
background: var(--surface2);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-header span.badge {
background: var(--accent);
color: #000;
font-size: 0.6rem;
padding: 2px 6px;
border-radius: 3px;
font-weight: 700;
}
.panel-body {
padding: 1rem;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: 6px;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: var(--accent);
background: rgba(200, 255, 87, 0.04);
}
.drop-icon {
font-size: 2rem;
opacity: 0.4;
display: block;
}
.drop-zone p {
font-size: 0.85rem;
color: var(--muted);
}
.drop-zone strong {
color: var(--accent);
font-weight: 500;
}
input[type=file] {
display: none;
}
.control-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
.control-row:last-child {
margin-bottom: 0;
}
.control-label {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--muted);
}
.control-label span {
font-family: 'Space Mono', monospace;
color: var(--accent);
font-size: 0.72rem;
}
input[type=range] {
width: 100%;
appearance: none;
height: 4px;
background: var(--surface2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg);
}
input[type=range]::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
}
.method-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.method-btn {
padding: 0.6rem 0.5rem;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
border-radius: 5px;
font-family: 'Space Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.method-btn:hover {
border-color: var(--accent);
color: var(--text);
}
.method-btn.active {
background: rgba(200, 255, 87, 0.1);
border-color: var(--accent);
color: var(--accent);
}
.run-btn {
width: 100%;
padding: 0.85rem;
background: var(--accent);
color: #000;
border: none;
border-radius: 6px;
font-family: 'Space Mono', monospace;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
cursor: pointer;
transition: all 0.15s;
}
.run-btn:hover {
background: #d8ff77;
transform: translateY(-1px);
}
.run-btn:active {
transform: translateY(0);
}
.run-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.stat-box {
background: var(--surface2);
border-radius: 5px;
padding: 0.6rem 0.75rem;
}
.stat-label {
font-size: 0.65rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.25rem;
}
.stat-value {
font-family: 'Space Mono', monospace;
font-size: 1rem;
font-weight: 700;
color: var(--accent);
}
.stat-value.muted {
color: var(--muted);
}
.palette-strip {
display: flex;
flex-wrap: wrap;
gap: 3px;
max-height: 80px;
overflow-y: auto;
}
.swatch {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: transform 0.1s;
}
.swatch:hover {
transform: scale(1.3);
z-index: 1;
position: relative;
}
.canvas-area {
display: flex;
flex-direction: column;
overflow: hidden;
}
.canvas-toolbar {
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
background: var(--surface);
}
.view-tabs {
display: flex;
gap: 0.25rem;
background: var(--surface2);
border-radius: 5px;
padding: 3px;
}
.view-tab {
padding: 0.35rem 0.9rem;
border: none;
background: transparent;
color: var(--muted);
font-family: 'Space Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
}
.view-tab.active {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.zoom-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s;
}
.zoom-btn:hover {
border-color: var(--accent2);
}
.zoom-label {
font-family: 'Space Mono', monospace;
font-size: 0.7rem;
color: var(--muted);
min-width: 40px;
text-align: center;
}
.download-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--accent2);
background: transparent;
color: var(--accent2);
border-radius: 4px;
font-family: 'Space Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.15s;
}
.download-btn:hover {
background: rgba(87, 200, 255, 0.1);
}
.download-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.canvas-viewport {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background:
radial-gradient(ellipse at 20% 50%, rgba(200, 255, 87, 0.03) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(87, 200, 255, 0.03) 0%, transparent 50%),
repeating-conic-gradient(#16161e 0% 25%, #17171f 0% 50%) 0 0 / 24px 24px;
}
.canvases-wrap {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.canvas-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.canvas-caption {
font-family: 'Space Mono', monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
display: block;
border: 1px solid var(--border);
border-radius: 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--muted);
text-align: center;
}
.empty-grid {
display: grid;
grid-template-columns: repeat(8, 24px);
gap: 2px;
opacity: 0.2;
}
.empty-pixel {
width: 24px;
height: 24px;
border-radius: 2px;
}
.progress-bar {
height: 2px;
background: var(--surface2);
border-radius: 1px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 1px;
width: 0%;
transition: width 0.1s linear;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.toggle-label {
font-size: 0.78rem;
color: var(--muted);
}
.toggle {
width: 36px;
height: 20px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
position: relative;
transition: background 0.2s;
}
.toggle.on {
background: rgba(200, 255, 87, 0.3);
border-color: var(--accent);
}
.toggle::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
background: var(--muted);
border-radius: 50%;
top: 2px;
left: 2px;
transition: all 0.2s;
}
.toggle.on::after {
left: 18px;
background: var(--accent);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
@keyframes pulse {
0%,
100% {
opacity: 1
}
50% {
opacity: 0.5
}
}
.processing {
animation: pulse 1.5s infinite;
}
</style>
</head>
<body>
<div class="scanlines"></div>
<header>
<div class="logo">PixelPalette</div>
<div class="tagline">AI像素畫 → 人工風格調色盤精簡</div>
</header>
<div class="main">
<div class="sidebar">
<div class="panel">
<div class="panel-header">輸入圖像</div>
<div class="panel-body">
<div class="drop-zone" id="dropZone">
<span class="drop-icon">⊞</span>
<p><strong>拖拽圖片</strong>到此處</p>
<p>或點擊選擇文件</p>
<p style="font-size:0.72rem; margin-top:0.25rem;">支持 PNG / GIF / BMP</p>
</div>
<input type="file" id="fileInput" accept="image/png,image/gif,image/bmp,image/jpeg,image/webp">
<div class="progress-bar" id="progressBar" style="display:none">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
顏色數量
<span class="badge" id="colorCountBadge">—</span>
</div>
<div class="panel-body">
<div class="control-row">
<div class="control-label">
目標顏色數
<span id="paletteSizeVal">32</span>
</div>
<input type="range" id="paletteSize" min="2" max="256" value="32" step="1">
</div>
<div class="control-row">
<div class="control-label">
抖動強度
<span id="ditherVal">關</span>
</div>
<input type="range" id="ditherLevel" min="0" max="4" value="0" step="1">
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">量化算法</div>
<div class="panel-body">
<div class="method-grid" style="grid-template-columns:1fr 1fr 1fr;">
<button class="method-btn active" data-method="median-cut">Median Cut</button>
<button class="method-btn" data-method="kmeans">K-Means</button>
<button class="method-btn" data-method="octree">Octree</button>
<button class="method-btn" data-method="popularity">頻率優先</button>
<button class="method-btn" data-method="slic" style="grid-column:span 2;" title="超像素聚類量化:將圖像分割爲緊湊超像素區域後聚類,保留邊緣清晰度">SLIC 超像素</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">預處理 / 後處理</div>
<div class="panel-body">
<div class="toggle-row">
<span class="toggle-label" title="雙邊濾波:在量化前平滑噪點,同時保留邊緣銳利度,適合高噪聲圖像">🔬 雙邊濾波預處理</span>
<div class="toggle" id="toggleBilateral"></div>
</div>
<div id="bilateralParams" style="display:none; margin-bottom:0.75rem; padding:0.5rem; background:var(--surface2); border-radius:5px; border:1px solid var(--border);">
<div class="control-row" style="margin-bottom:0.5rem;">
<div class="control-label">空間半徑 σ_s <span id="bilateralSigmaSVal">3</span></div>
<input type="range" id="bilateralSigmaS" min="1" max="8" value="3" step="1">
</div>
<div class="control-row" style="margin-bottom:0;">
<div class="control-label">色彩強度 σ_r <span id="bilateralSigmaRVal">40</span></div>
<input type="range" id="bilateralSigmaR" min="5" max="100" value="40" step="5">
</div>
</div>
<div class="toggle-row">
<span class="toggle-label">亮度矯正</span>
<div class="toggle" id="toggleBrightness"></div>
</div>
<div class="toggle-row">
<span class="toggle-label">飽和度增強</span>
<div class="toggle" id="toggleSaturation"></div>
</div>
<div class="toggle-row">
<span class="toggle-label">邊緣銳化</span>
<div class="toggle" id="toggleSharpen"></div>
</div>
<div class="control-row" style="margin-bottom:0">
<div class="control-label">
色彩容差(合併相近色)
<span id="toleranceVal">12</span>
</div>
<input type="range" id="tolerance" min="0" max="64" value="12" step="1">
</div>
</div>
</div>
<button class="run-btn" id="runBtn" disabled>▶ 開始精簡</button>
<div class="panel" id="palettePanel" style="display:none">
<div class="panel-header">
輸出調色盤
<span class="badge" id="outColorCount">0色</span>
</div>
<div class="panel-body">
<div class="palette-strip" id="paletteStrip"></div>
</div>
</div>
<div class="stats-grid" id="statsGrid" style="display:none">
<div class="stat-box">
<div class="stat-label">原始顏色</div>
<div class="stat-value" id="statOriginal">—</div>
</div>
<div class="stat-box">
<div class="stat-label">精簡後</div>
<div class="stat-value" id="statOutput">—</div>
</div>
<div class="stat-box">
<div class="stat-label">壓縮比</div>
<div class="stat-value" id="statRatio">—</div>
</div>
<div class="stat-box">
<div class="stat-label">處理用時</div>
<div class="stat-value muted" id="statTime">—</div>
</div>
</div>
</div>
<div class="canvas-area">
<div class="canvas-toolbar">
<div class="view-tabs">
<button class="view-tab active" data-view="both">對比</button>
<button class="view-tab" data-view="original">原圖</button>
<button class="view-tab" data-view="result">結果</button>
</div>
<div class="zoom-controls">
<button class="zoom-btn" id="zoomOut">−</button>
<span class="zoom-label" id="zoomLabel">1×</span>
<button class="zoom-btn" id="zoomIn">+</button>
</div>
<button class="download-btn" id="downloadBtn" disabled>↓ 導出 PNG</button>
</div>
<div class="canvas-viewport" id="viewport">
<div class="empty-state" id="emptyState">
<div class="empty-grid" id="emptyGrid"></div>
<p style="font-size:0.85rem;">請先上傳像素畫圖像</p>
<p style="font-size:0.75rem; opacity:0.6;">支持AI生成的像素圖、精靈圖、圖標等</p>
</div>
<div class="canvases-wrap" id="canvasesWrap" style="display:none">
<div class="canvas-group" id="groupOrig">
<div class="canvas-caption">原圖</div>
<canvas id="canvasOrig"></canvas>
</div>
<div class="canvas-group" id="groupResult">
<div class="canvas-caption">精簡後</div>
<canvas id="canvasResult"></canvas>
</div>
</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
// Build decorative empty grid
const colors = ['#c8ff57', '#57c8ff', '#ff5787', '#ffb857', '#8857ff', '#57ffb8'];
const emptyGrid = $('emptyGrid');
for (let i = 0; i < 32; i++) {
const d = document.createElement('div');
d.className = 'empty-pixel';
d.style.background = Math.random() < 0.6 ? colors[Math.floor(Math.random() * colors.length)] : '#1e1e2a';
emptyGrid.appendChild(d);
}
// State
let srcImageData = null;
let resultImageData = null;
let zoom = 1;
let currentView = 'both';
let toggleStates = { toggleBrightness: false, toggleSaturation: false, toggleSharpen: false, toggleBilateral: false };
// Toggles
['toggleBrightness', 'toggleSaturation', 'toggleSharpen', 'toggleBilateral'].forEach(id => {
$(id).addEventListener('click', () => {
toggleStates[id] = !toggleStates[id];
$(id).classList.toggle('on', toggleStates[id]);
if (id === 'toggleBilateral') {
$('bilateralParams').style.display = toggleStates[id] ? 'block' : 'none';
}
});
});
// Sliders
[['paletteSize', 'paletteSizeVal'], ['ditherLevel', 'ditherVal'], ['tolerance', 'toleranceVal']].forEach(([id, out]) => {
$(id).addEventListener('input', () => {
let v = $(id).value;
if (id === 'ditherLevel') v = ['關', '低', '中', '高', '最高'][v];
$(out).textContent = v;
});
});
$('bilateralSigmaS').addEventListener('input', () => { $('bilateralSigmaSVal').textContent = $('bilateralSigmaS').value; });
$('bilateralSigmaR').addEventListener('input', () => { $('bilateralSigmaRVal').textContent = $('bilateralSigmaR').value; });
// File input
const dropZone = $('dropZone');
const fileInput = $('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) loadFile(fileInput.files[0]); });
function loadFile(file) {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
srcImageData = ctx.getImageData(0, 0, img.width, img.height);
const origCanvas = $('canvasOrig');
origCanvas.width = img.width; origCanvas.height = img.height;
origCanvas.getContext('2d').putImageData(srcImageData, 0, 0);
const colorCount = countColors(srcImageData);
$('colorCountBadge').textContent = colorCount.toLocaleString() + '色';
$('statOriginal').textContent = colorCount.toLocaleString();
$('runBtn').disabled = false;
dropZone.querySelector('p:first-of-type').innerHTML = `<strong>${file.name}</strong>`;
dropZone.querySelector('p:nth-of-type(2)').textContent = `${img.width}×${img.height}`;
showCanvases();
updateZoom();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function countColors(imgData) {
const set = new Set();
for (let i = 0; i < imgData.data.length; i += 4) {
if (imgData.data[i + 3] < 128) continue;
set.add((imgData.data[i] << 16) | (imgData.data[i + 1] << 8) | imgData.data[i + 2]);
}
return set.size;
}
// Method selection
let selectedMethod = 'median-cut';
document.querySelectorAll('.method-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.method-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedMethod = btn.dataset.method;
});
});
// View tabs
document.querySelectorAll('.view-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentView = tab.dataset.view;
updateView();
});
});
function updateView() {
const go = $('groupOrig'), gr = $('groupResult');
if (!go || !gr) return;
if (currentView === 'both') { go.style.display = 'flex'; gr.style.display = 'flex'; }
else if (currentView === 'original') { go.style.display = 'flex'; gr.style.display = 'none'; }
else { go.style.display = 'none'; gr.style.display = 'flex'; }
}
// Zoom
let zoomLevels = [0.5, 1, 2, 3, 4, 6, 8, 12, 16];
let zoomIdx = 1;
$('zoomIn').addEventListener('click', () => { if (zoomIdx < zoomLevels.length - 1) { zoomIdx++; updateZoom(); } });
$('zoomOut').addEventListener('click', () => { if (zoomIdx > 0) { zoomIdx--; updateZoom(); } });
function updateZoom() {
zoom = zoomLevels[zoomIdx];
$('zoomLabel').textContent = zoom + '×';
const canvases = document.querySelectorAll('.canvas-area canvas');
canvases.forEach(c => {
c.style.width = c.width * zoom + 'px';
c.style.height = c.height * zoom + 'px';
});
}
function showCanvases() {
$('emptyState').style.display = 'none';
$('canvasesWrap').style.display = 'flex';
}
// ===== QUANTIZATION ALGORITHMS =====
// Median Cut
function medianCut(pixels, maxColors) {
let buckets = [pixels.slice()];
while (buckets.length < maxColors) {
// Find bucket with largest range
let maxRange = -1, splitIdx = 0;
buckets.forEach((bucket, i) => {
if (bucket.length === 0) return;
const ranges = [0, 1, 2].map(ch => {
let mn = 255, mx = 0;
bucket.forEach(p => { mn = Math.min(mn, p[ch]); mx = Math.max(mx, p[ch]); });
return mx - mn;
});
const r = Math.max(...ranges);
if (r > maxRange) { maxRange = r; splitIdx = i; }
});
if (maxRange === 0) break;
const bucket = buckets.splice(splitIdx, 1)[0];
const ranges = [0, 1, 2].map(ch => {
let mn = 255, mx = 0;
bucket.forEach(p => { mn = Math.min(mn, p[ch]); mx = Math.max(mx, p[ch]); });
return mx - mn;
});
const sortCh = ranges.indexOf(Math.max(...ranges));
bucket.sort((a, b) => a[sortCh] - b[sortCh]);
const mid = Math.floor(bucket.length / 2);
buckets.push(bucket.slice(0, mid));
buckets.push(bucket.slice(mid));
}
return buckets.filter(b => b.length > 0).map(bucket => {
let r = 0, g = 0, b = 0;
bucket.forEach(p => { r += p[0]; g += p[1]; b += p[2]; });
const n = bucket.length;
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
});
}
// K-Means
function kmeans(pixels, k, iterations = 10) {
let centroids = [];
const step = Math.floor(pixels.length / k);
for (let i = 0; i < k; i++) centroids.push([...pixels[i * step] || pixels[0]]);
for (let iter = 0; iter < iterations; iter++) {
const clusters = Array.from({ length: k }, () => []);
pixels.forEach(p => {
let minD = Infinity, minI = 0;
centroids.forEach((c, i) => {
const d = (p[0] - c[0]) ** 2 + (p[1] - c[1]) ** 2 + (p[2] - c[2]) ** 2;
if (d < minD) { minD = d; minI = i; }
});
clusters[minI].push(p);
});
let changed = false;
clusters.forEach((cluster, i) => {
if (cluster.length === 0) return;
let r = 0, g = 0, b = 0;
cluster.forEach(p => { r += p[0]; g += p[1]; b += p[2]; });
const n = cluster.length;
const nr = Math.round(r / n), ng = Math.round(g / n), nb = Math.round(b / n);
if (nr !== centroids[i][0] || ng !== centroids[i][1] || nb !== centroids[i][2]) {
centroids[i] = [nr, ng, nb]; changed = true;
}
});
if (!changed) break;
}
return centroids;
}
// Octree (simplified)
function octreeQuant(pixels, maxColors) {
// Use a simplified version: histogram + merge small buckets
const hist = {};
pixels.forEach(p => {
const key = `${p[0] >> 2},${p[1] >> 2},${p[2] >> 2}`;
if (!hist[key]) hist[key] = { r: 0, g: 0, b: 0, count: 0, rr: p[0] >> 2, gg: p[1] >> 2, bb: p[2] >> 2 };
hist[key].r += p[0]; hist[key].g += p[1]; hist[key].b += p[2]; hist[key].count++;
});
let buckets = Object.values(hist).sort((a, b) => b.count - a.count);
buckets = buckets.slice(0, maxColors);
return buckets.map(b => [Math.round(b.r / b.count), Math.round(b.g / b.count), Math.round(b.b / b.count)]);
}
// Popularity
function popularityQuant(pixels, maxColors) {
const hist = {};
pixels.forEach(p => {
const key = (p[0] << 16) | (p[1] << 8) | p[2];
hist[key] = (hist[key] || 0) + 1;
});
return Object.entries(hist)
.sort((a, b) => b[1] - a[1])
.slice(0, maxColors)
.map(([k]) => [(k >> 16) & 255, (k >> 8) & 255, k & 255]);
}
// Nearest color in palette
function nearestColor(r, g, b, palette) {
let minD = Infinity, best = 0;
palette.forEach((c, i) => {
const d = (r - c[0]) ** 2 + (g - c[1]) ** 2 + (b - c[2]) ** 2;
if (d < minD) { minD = d; best = i; }
});
return best;
}
// Merge similar colors by tolerance
function mergeSimilar(palette, tolerance) {
const tol2 = tolerance * tolerance;
const result = [];
const merged = new Array(palette.length).fill(false);
for (let i = 0; i < palette.length; i++) {
if (merged[i]) continue;
let group = [palette[i]];
for (let j = i + 1; j < palette.length; j++) {
if (!merged[j]) {
const d = (palette[i][0] - palette[j][0]) ** 2 + (palette[i][1] - palette[j][1]) ** 2 + (palette[i][2] - palette[j][2]) ** 2;
if (d <= tol2) { group.push(palette[j]); merged[j] = true; }
}
}
const avg = group.reduce((a, c) => [a[0] + c[0], a[1] + c[1], a[2] + c[2]], [0, 0, 0]);
result.push([Math.round(avg[0] / group.length), Math.round(avg[1] / group.length), Math.round(avg[2] / group.length)]);
}
return result;
}
// Floyd-Steinberg dither
function floydSteinberg(imgData, palette, strength) {
const w = imgData.width, h = imgData.height;
const data = new Float32Array(imgData.data);
const result = new Uint8ClampedArray(imgData.data);
const s = strength / 4;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 4;
const or = data[idx], og = data[idx + 1], ob = data[idx + 2];
const ci = nearestColor(Math.max(0, Math.min(255, or)), Math.max(0, Math.min(255, og)), Math.max(0, Math.min(255, ob)), palette);
result[idx] = palette[ci][0]; result[idx + 1] = palette[ci][1]; result[idx + 2] = palette[ci][2]; result[idx + 3] = imgData.data[idx + 3];
const er = or - palette[ci][0], eg = og - palette[ci][1], eb = ob - palette[ci][2];
const spread = [[1, 0, 7 / 16], [- 1, 1, 3 / 16], [0, 1, 5 / 16], [1, 1, 1 / 16]];
spread.forEach(([dx, dy, w2]) => {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
const ni = (ny * w + nx) * 4;
data[ni] += er * w2 * s * 4;
data[ni + 1] += eg * w2 * s * 4;
data[ni + 2] += eb * w2 * s * 4;
}
});
}
}
return result;
}
// Post process
function adjustSaturation(data, factor) {
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = Math.max(0, Math.min(255, gray + (r - gray) * factor));
data[i + 1] = Math.max(0, Math.min(255, gray + (g - gray) * factor));
data[i + 2] = Math.max(0, Math.min(255, gray + (b - gray) * factor));
}
}
function adjustBrightness(data) {
let sum = 0, cnt = 0;
for (let i = 0; i < data.length; i += 4) { sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; cnt++; }
const avg = sum / cnt;
const factor = avg < 100 ? 1.15 : avg > 160 ? 0.9 : 1.0;
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.max(0, Math.min(255, data[i] * factor));
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] * factor));
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] * factor));
}
}
// Sharpen (simple 3x3 unsharp)
function sharpen(data, w, h) {
const orig = new Uint8ClampedArray(data);
const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
for (let ch = 0; ch < 3; ch++) {
let sum = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const ki = (ky + 1) * 3 + (kx + 1);
const pi = ((y + ky) * w + (x + kx)) * 4 + ch;
sum += orig[pi] * kernel[ki];
}
}
data[(y * w + x) * 4 + ch] = Math.max(0, Math.min(255, sum));
}
}
}
}
// ===== BILATERAL FILTER (Pre-processing) =====
// Preserves edges while smoothing color noise — ideal before quantization.
// Uses a spatial Gaussian (σ_s) and a color-range Gaussian (σ_r).
function bilateralFilter(imgData, sigmaS, sigmaR) {
const w = imgData.width, h = imgData.height;
const src = imgData.data;
const dst = new Uint8ClampedArray(src);
const radius = Math.ceil(sigmaS * 2);
const sigmaS2 = 2 * sigmaS * sigmaS;
const sigmaR2 = 2 * sigmaR * sigmaR;
// Precompute spatial Gaussian kernel weights
const kernelSize = radius * 2 + 1;
const spatialW = new Float32Array(kernelSize * kernelSize);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
spatialW[(dy + radius) * kernelSize + (dx + radius)] =
Math.exp(-(dx * dx + dy * dy) / sigmaS2);
}
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const ci = (y * w + x) * 4;
if (src[ci + 3] < 128) continue;
const cr = src[ci], cg = src[ci + 1], cb = src[ci + 2];
let sumR = 0, sumG = 0, sumB = 0, sumW = 0;
for (let dy = -radius; dy <= radius; dy++) {
const ny = Math.max(0, Math.min(h - 1, y + dy));
for (let dx = -radius; dx <= radius; dx++) {
const nx = Math.max(0, Math.min(w - 1, x + dx));
const ni = (ny * w + nx) * 4;
if (src[ni + 3] < 128) continue;
const dr = src[ni] - cr, dg = src[ni + 1] - cg, db = src[ni + 2] - cb;
const colorDist2 = dr * dr + dg * dg + db * db;
const sw = spatialW[(dy + radius) * kernelSize + (dx + radius)];
const rangeW = Math.exp(-colorDist2 / sigmaR2);
const weight = sw * rangeW;
sumR += src[ni] * weight;
sumG += src[ni + 1] * weight;
sumB += src[ni + 2] * weight;
sumW += weight;
}
}
if (sumW > 0) {
dst[ci] = Math.round(sumR / sumW);
dst[ci + 1] = Math.round(sumG / sumW);
dst[ci + 2] = Math.round(sumB / sumW);
}
dst[ci + 3] = src[ci + 3];
}
}
return new ImageData(dst, w, h);
}
// ===== SLIC SUPERPIXEL QUANTIZATION =====
// Simple Linear Iterative Clustering: partitions the image into compact,
// perceptually uniform superpixels in [CIELAB + XY] space, then
// applies k-means on their mean colors to derive the palette.
function slicQuant(imgData, maxColors) {
const w = imgData.width, h = imgData.height;
const data = imgData.data;
const N = w * h;
// --- Convert RGB→Lab for perceptual clustering ---
function rgbToLab(r, g, b) {
// sRGB → linear
let rl = r / 255, gl = g / 255, bl = b / 255;
rl = rl > 0.04045 ? Math.pow((rl + 0.055) / 1.055, 2.4) : rl / 12.92;
gl = gl > 0.04045 ? Math.pow((gl + 0.055) / 1.055, 2.4) : gl / 12.92;
bl = bl > 0.04045 ? Math.pow((bl + 0.055) / 1.055, 2.4) : bl / 12.92;
// linear → XYZ (D65)
let X = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375;
let Y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750;
let Z = rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041;
X /= 0.95047; Z /= 1.08883;
function f(t) { return t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116; }
const fx = f(X), fy = f(Y), fz = f(Z);
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
// Build pixel Lab + alpha arrays
const L = new Float32Array(N), A = new Float32Array(N), B_ = new Float32Array(N);
const alpha = new Uint8Array(N);
for (let i = 0; i < N; i++) {
const pi = i * 4;
alpha[i] = data[pi + 3];
if (data[pi + 3] >= 128) {
const lab = rgbToLab(data[pi], data[pi + 1], data[pi + 2]);
L[i] = lab[0]; A[i] = lab[1]; B_[i] = lab[2];
}
}
// --- Initialize superpixel centers on a grid ---
// Target superpixel count: use enough to capture structure but manageable
const S = Math.max(2, Math.round(Math.sqrt(N / (maxColors * 4))));
const gridCols = Math.ceil(w / S), gridRows = Math.ceil(h / S);
const K = gridCols * gridRows; // actual superpixel count
const cL = new Float32Array(K), cA = new Float32Array(K), cB = new Float32Array(K);
const cX = new Float32Array(K), cY = new Float32Array(K);
let ki = 0;
for (let gy = 0; gy < gridRows; gy++) {
for (let gx = 0; gx < gridCols; gx++) {
const cx = Math.min(Math.round((gx + 0.5) * S), w - 1);
const cy = Math.min(Math.round((gy + 0.5) * S), h - 1);
const idx = cy * w + cx;
cL[ki] = L[idx]; cA[ki] = A[idx]; cB[ki] = B_[idx];
cX[ki] = cx; cY[ki] = cy;
ki++;
}
}
const labels = new Int32Array(N).fill(-1);
const distances = new Float32Array(N).fill(Infinity);
const m = 10; // compactness factor (spatial vs color weight)
const mOverS = m / S;
// --- SLIC iterations ---
const ITERS = 6;
for (let iter = 0; iter < ITERS; iter++) {
distances.fill(Infinity);
for (let k = 0; k < K; k++) {
const x0 = Math.max(0, Math.round(cX[k]) - S);
const x1 = Math.min(w - 1, Math.round(cX[k]) + S);
const y0 = Math.max(0, Math.round(cY[k]) - S);
const y1 = Math.min(h - 1, Math.round(cY[k]) + S);
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const pidx = py * w + px;
if (alpha[pidx] < 128) continue;
const dL = L[pidx] - cL[k], dA = A[pidx] - cA[k], dB = B_[pidx] - cB[k];
const dc = Math.sqrt(dL * dL + dA * dA + dB * dB);
const dx = px - cX[k], dy = py - cY[k];
const ds = Math.sqrt(dx * dx + dy * dy);
const D = dc + mOverS * ds;
if (D < distances[pidx]) {
distances[pidx] = D;
labels[pidx] = k;
}
}
}
}
// Recompute cluster centers
const sumL = new Float64Array(K), sumA = new Float64Array(K), sumB = new Float64Array(K);
const sumX = new Float64Array(K), sumY = new Float64Array(K), cnt = new Int32Array(K);
for (let i = 0; i < N; i++) {
const k = labels[i];
if (k < 0) continue;
sumL[k] += L[i]; sumA[k] += A[i]; sumB[k] += B_[i];
sumX[k] += i % w; sumY[k] += Math.floor(i / w);
cnt[k]++;
}
for (let k = 0; k < K; k++) {
if (cnt[k] > 0) {
cL[k] = sumL[k] / cnt[k]; cA[k] = sumA[k] / cnt[k]; cB[k] = sumB[k] / cnt[k];
cX[k] = sumX[k] / cnt[k]; cY[k] = sumY[k] / cnt[k];
}
}
}
// --- Collect mean RGB per superpixel ---
const spR = new Float64Array(K), spG = new Float64Array(K), spBc = new Float64Array(K);
const spCnt = new Int32Array(K);
for (let i = 0; i < N; i++) {
const k = labels[i];
if (k < 0) continue;
const pi = i * 4;
spR[k] += data[pi]; spG[k] += data[pi + 1]; spBc[k] += data[pi + 2];
spCnt[k]++;
}
const spColors = [];
for (let k = 0; k < K; k++) {
if (spCnt[k] > 0) {
spColors.push([
Math.round(spR[k] / spCnt[k]),
Math.round(spG[k] / spCnt[k]),
Math.round(spBc[k] / spCnt[k])
]);
}
}
// --- Final k-means on superpixel colors to get palette ---
return kmeans(spColors, Math.min(maxColors, spColors.length), 12);
}
// 新增:用於顏色高亮功能的全局狀態
let currentPalette = [];
let isPostProcessed = false;
let originalResultPixels = null; // 備份未高亮前的結果圖數據
$('runBtn').addEventListener('click', async () => {
if (!srcImageData) return;
const t0 = performance.now();
$('runBtn').disabled = true;
$('runBtn').textContent = '⟳ 處理中...';
$('runBtn').classList.add('processing');
$('progressBar').style.display = 'block';
$('progressFill').style.width = '5%';
await new Promise(r => setTimeout(r, 20));
const maxColors = parseInt($('paletteSize').value);
const tolerance = parseInt($('tolerance').value);
const ditherLevel = parseInt($('ditherLevel').value);
const w = srcImageData.width, h = srcImageData.height;
// --- PRE-PROCESSING: Bilateral Filter ---
let workingData = srcImageData;
if (toggleStates.toggleBilateral) {
$('runBtn').textContent = '⟳ 雙邊濾波...';
await new Promise(r => setTimeout(r, 10));
const sigmaS = parseFloat($('bilateralSigmaS').value);
const sigmaR = parseFloat($('bilateralSigmaR').value);
workingData = bilateralFilter(srcImageData, sigmaS, sigmaR);
}
$('progressFill').style.width = '20%';
await new Promise(r => setTimeout(r, 10));
// Sample pixels (skip transparent)
const pixels = [];
for (let i = 0; i < workingData.data.length; i += 4) {
if (workingData.data[i + 3] >= 128)
pixels.push([workingData.data[i], workingData.data[i + 1], workingData.data[i + 2]]);
}
// Subsample for speed
const maxSample = 50000;
const sampled = pixels.length > maxSample
? pixels.filter((_, i) => i % (Math.ceil(pixels.length / maxSample)) === 0)
: pixels;
$('progressFill').style.width = '35%';
await new Promise(r => setTimeout(r, 10));
let palette;
if (selectedMethod === 'median-cut') palette = medianCut(sampled, maxColors);
else if (selectedMethod === 'kmeans') palette = kmeans(sampled, Math.min(maxColors, sampled.length));
else if (selectedMethod === 'octree') palette = octreeQuant(sampled, maxColors);
else if (selectedMethod === 'slic') {
$('runBtn').textContent = '⟳ SLIC 分割...';
await new Promise(r => setTimeout(r, 10));
palette = slicQuant(workingData, maxColors);
}
else palette = popularityQuant(sampled, maxColors);
$('progressFill').style.width = '55%';
await new Promise(r => setTimeout(r, 10));
if (tolerance > 0) palette = mergeSimilar(palette, tolerance);
// Apply palette to image (map from workingData for bilateral pre-processed result)
let resultData;
if (ditherLevel > 0) {
resultData = floydSteinberg(workingData, palette, ditherLevel);
} else {
resultData = new Uint8ClampedArray(workingData.data);
for (let i = 0; i < resultData.length; i += 4) {
if (resultData[i + 3] < 128) continue;
const ci = nearestColor(resultData[i], resultData[i + 1], resultData[i + 2], palette);
resultData[i] = palette[ci][0]; resultData[i + 1] = palette[ci][1]; resultData[i + 2] = palette[ci][2];
}
}
$('progressFill').style.width = '75%';
await new Promise(r => setTimeout(r, 10));
// Post-process
if (toggleStates.toggleBrightness) adjustBrightness(resultData);
if (toggleStates.toggleSaturation) adjustSaturation(resultData, 1.3);
if (toggleStates.toggleSharpen) sharpen(resultData, w, h);
$('progressFill').style.width = '90%';
await new Promise(r => setTimeout(r, 10));
// Draw result
const rc = $('canvasResult');
rc.width = w; rc.height = h;
const rctx = rc.getContext('2d');
resultImageData = new ImageData(resultData, w, h);
rctx.putImageData(resultImageData, 0, 0);
updateZoom();
showCanvases();
updateView();
// Actual color count in result
const actualCount = countColors(resultImageData);
// Update palette display
$('palettePanel').style.display = 'block';
const strip = $('paletteStrip');
strip.innerHTML = '';
palette.slice(0, 128).forEach(c => {
const sw = document.createElement('div');
sw.className = 'swatch';
sw.style.background = `rgb(${c[0]},${c[1]},${c[2]})`;
sw.title = `#${c[0].toString(16).padStart(2, '0')}${c[1].toString(16).padStart(2, '0')}${c[2].toString(16).padStart(2, '0')}`;
strip.appendChild(sw);
});
$('outColorCount').textContent = actualCount + '色';
// 保存當前調色盤與後處理狀態
currentPalette = palette;
isPostProcessed = toggleStates.toggleBrightness || toggleStates.toggleSaturation || toggleStates.toggleSharpen;
// 備份一份乾淨的、沒有高亮白色像素的結果圖數據
originalResultPixels = new Uint8ClampedArray(resultData);
// Stats
const origCount = parseInt($('statOriginal').textContent.replace(/,/g, '')) || countColors(srcImageData);
$('statsGrid').style.display = 'grid';
$('statOriginal').textContent = origCount.toLocaleString();
$('statOutput').textContent = actualCount.toLocaleString();
const ratio = origCount > 0 ? (origCount / actualCount).toFixed(1) + '×' : '—';
$('statRatio').textContent = ratio;
$('statTime').textContent = ((performance.now() - t0) / 1000).toFixed(2) + 's';
$('progressFill').style.width = '100%';
setTimeout(() => { $('progressBar').style.display = 'none'; $('progressFill').style.width = '0%'; }, 500);
$('downloadBtn').disabled = false;
$('runBtn').disabled = false;
$('runBtn').textContent = '▶ 重新精簡';
$('runBtn').classList.remove('processing');
});
// ===== 新增:調色盤顏色像素高亮功能 =====
const strip = $('paletteStrip');
// 鼠標移入色塊:將對應顏色在畫布中變成白色
strip.addEventListener('mouseover', (e) => {
// 確保移入的是色塊,且未開啓後處理,且有備份的數據
if (!e.target.classList.contains('swatch') || isPostProcessed || !originalResultPixels) return;
const rc = $('canvasResult');
const rctx = rc.getContext('2d');
const w = resultImageData.width;
const h = resultImageData.height;
// 獲取當前懸停色塊的 RGB 顏色值
const rgbStr = e.target.style.background; // 格式通常爲 "rgb(r, g, b)"
const match = rgbStr.match(/\d+/g);
if (!match) return;
const [sr, sg, sb] = match.map(Number);
// 基於備份的像素數據進行修改,避免多次懸停導致色彩污染
const tempPixels = new Uint8ClampedArray(originalResultPixels);
// 遍歷像素,尋找相同的顏色並替換爲白色 (255, 255, 255)
for (let i = 0; i < tempPixels.length; i += 4) {
if (tempPixels[i + 3] < 128) continue; // 跳過透明像素
if (tempPixels[i] === sr && tempPixels[i + 1] === sg && tempPixels[i + 2] === sb) {
tempPixels[i] = 255; // R
tempPixels[i + 1] = 255; // G
tempPixels[i + 2] = 255; // B
}
}
// 渲染高亮後的圖像
const highlightedData = new ImageData(tempPixels, w, h);
rctx.putImageData(highlightedData, 0, 0);
});
// 鼠標移出色塊:恢復原狀
strip.addEventListener('mouseout', (e) => {
if (!e.target.classList.contains('swatch') || isPostProcessed || !originalResultPixels) return;
const rc = $('canvasResult');
const rctx = rc.getContext('2d');
// 直接用備份的乾淨數據重新覆蓋畫布
const restoredData = new ImageData(new Uint8ClampedArray(originalResultPixels), resultImageData.width, resultImageData.height);
rctx.putImageData(restoredData, 0, 0);
});
// Download
$('downloadBtn').addEventListener('click', () => {
if (!resultImageData) return;
const c = document.createElement('canvas');
c.width = resultImageData.width; c.height = resultImageData.height;
c.getContext('2d').putImageData(resultImageData, 0, 0);
const a = document.createElement('a');
a.download = 'pixelpalette_output.png';
a.href = c.toDataURL('image/png');
a.click();
});
</script>
</body>
</html>最后修改: 羽落 (2026-05-20 14:16:49)
@抓根寶 寫道: 《冰與火之歌》裏,龍被鎖在地下幾十年,瘦成皮包骨,被丹妮莉絲鎖鏈捆綁、強行騎乘、當作戰爭機器
如果使用了AI輔助寫作,請注意覈實關鍵信息。比如這一處有顯而易見的錯誤:
原著中丹妮莉絲曾因龍傷害平民的風險,將龍臨時鎖在彌林大金字塔下,時間沒有幾十年。按時餵食,不會“瘦成皮包骨”。坦格利安家族與龍具有血緣綁定,並非強行騎乘。
當虯龍從雲端墜入莽林,失去魔法與雙翼,它面臨的根本命題並非如何重返天空,而是如何在泥土中重新定義自身。弄髒鱗與爪,絕非對過往的背叛,而是一場向生命本身的獻祭。龍鱗的莊嚴不在於瓷器般的完美,而在於雷暴與巖壁的撕扯中仍能護住那顆跳動的心臟。捕獵留下的劃痕,不是瑕疵,而是用最原始的身軀與大地搏鬥後贏得的勳章——這並非弄髒,乃是鐫刻。
由此,所謂“淪爲野獸”實則是一種歸鄉。林中野狼滿身瘡痍,每一道傷疤都是它作爲森林合法居民的身份證明。當龍憑一己之力抓破第一塊樹皮、捕獲第一隻獵物,它所失去的只是那種必須倚仗飛行與魔法才能維持的虛妄尊嚴,而贏得的,卻是作爲大地之子真實而滾燙的生存權利。在這片無情的森林裏,沒有光環加持的生存,纔是最高貴的尊嚴。
真正的奇蹟於此顯露。奇蹟並非失去的翅膀重新長出,也非英雄從天而降的拯救,而在於絕境之中,你發現自己竟然仍能戰鬥。多年之後,當爪刃殘損、鱗片捲曲,那份尊嚴早已不再依賴於能否飛翔,而凝聚於傷口癒合後留下的那道最深的疤,以及凝視夕陽時眼裏依然沒有熄滅的光。
因此,最該追問的不是該不該弄髒爪牙去換取食物,而是當食物入口時,是否還能嚐出風的味道;傷痕累累的夜晚,是否還能夢見飛翔。若尚能如此,那麼這條龍便未曾墮落——它本身就是奇蹟,一個由天空墜落,卻在泥土裏紮根重生的真正生命。
去吧,弄髒你的爪,去與野狼爭奪或分享同一片領地。在沒有任何魔力加身的搏殺裏,你所譜寫的,恰是一條龍所能獻予自己最壯烈的史詩。
項目:https://github.com/theamusing/perfectPixel
演示網頁:https://theamusing.github.io/perfectPixel_webdemo/
原理及宣傳視頻:https://www.bilibili.com/video/BV1p3raB8EMh/
使用示例:
ai原圖:
用畫圖軟件簡單覆蓋文字並切割後使用這個項目得到:



不知道什麼時候ai生圖能進步到不需要這個項目
有 4 位朋友喜欢这篇文章:龍爪翻書, SmallDragon, shiningdracon, 镜中龙影
一隻長得像是世界樹的巨龍
摺疊大段AI生成內容
支持加入摺疊功能,在有大量圖/文字的帖子中能讓瀏覽體驗好很多
回到話題的討論,“應該”預設的主體是什麼?全社會,還是創作社區,亦或鱗目界域這樣的論壇?如果由虐龍癖對人類社會的潛在危害而呼籲論壇去禁止,無形中假定了論壇與社會的立場是一致的。
而且這裏的龍難以被替換爲一般生物,或許虐待龍的行爲中有近似屠龍的象徵意味,從而凹顯自身的偉岸與支配地位。我自己是懶得理,畢竟意淫是爲數不多的自由了。
有 2 位朋友喜欢这篇文章:NancalaStarry, Lunamis午月
[↑] @shiningdracon 寫道: 這個帖子有點意義不明。除了開頭虛構了一個場景之外就與龍再無關係了。如果你是想討論動物保護相關話題的話,其實不必硬和龍套關係?畢竟這個板塊沒限制主題必須和龍有關。雖然你做了這個免責聲明,我還是需要問一下 …
複製粘貼ai文給我看得犯惡心了都...不考慮限制一下嗎,感覺多幾篇這樣的論壇就完全沒法看了
有 1 位朋友喜欢这篇文章:Lunamis午月
多餘換行清除工具,用於清除文本間多於一個的換行。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多余换行去除工具</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", Arial, sans-serif;
}
body {
background-color: #f5f7fa;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08);
padding: 30px;
}
h1 {
text-align: center;
color: #333333;
margin-bottom: 30px;
font-size: 24px;
}
.text-area-group {
margin-bottom: 25px;
}
label {
display: block;
margin-bottom: 8px;
color: #555555;
font-weight: 500;
font-size: 16px;
}
textarea {
width: 100%;
height: 200px;
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 8px;
resize: vertical;
font-size: 14px;
line-height: 1.6;
color: #333;
outline: none;
transition: border-color 0.3s ease;
}
textarea:focus {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
.btn-group {
display: flex;
gap: 12px;
margin-bottom: 25px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #409eff;
color: #ffffff;
}
.btn-primary:hover {
background-color: #337ecc;
}
.btn-secondary {
background-color: #f0f2f5;
color: #555555;
}
.btn-secondary:hover {
background-color: #e4e6eb;
}
.tip {
text-align: center;
color: #999999;
font-size: 12px;
margin-top: 10px;
}
.copy-success {
position: fixed;
top: 20px;
right: 20px;
background-color: #67c23a;
color: #ffffff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.copy-success.show {
opacity: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>多余换行去除工具</h1>
<!-- 输入文本区域 -->
<div class="text-area-group">
<label for="input-text">待处理文本(可粘贴含多余换行的内容)</label>
<textarea id="input-text" placeholder="请输入或粘贴需要去除多余换行的文本..."></textarea>
</div>
<!-- 操作按钮组 -->
<div class="btn-group">
<button class="btn-primary" id="process-btn">去除多余换行</button>
<button class="btn-secondary" id="clear-input-btn">清空输入</button>
<button class="btn-secondary" id="clear-output-btn">清空输出</button>
<button class="btn-secondary" id="copy-output-btn">复制输出结果</button>
</div>
<!-- 输出文本区域 -->
<div class="text-area-group">
<label for="output-text">处理后文本(已去除多余非正常换行)</label>
<textarea id="output-text" placeholder="处理后的结果将显示在这里..." readonly></textarea>
</div>
<div class="tip">提示:工具会保留单个有效换行,去除连续多个换行(空行)</div>
</div>
<!-- 复制成功提示 -->
<div class="copy-success" id="copy-tip">复制成功!</div>
<script>
// 获取页面元素
const inputText = document.getElementById('input-text');
const outputText = document.getElementById('output-text');
const processBtn = document.getElementById('process-btn');
const clearInputBtn = document.getElementById('clear-input-btn');
const clearOutputBtn = document.getElementById('clear-output-btn');
const copyOutputBtn = document.getElementById('copy-output-btn');
const copyTip = document.getElementById('copy-tip');
/**
* 核心功能:去除多余换行
* 逻辑:
* 1. 匹配连续多个换行(\n\n+ 匹配连续2个及以上换行,兼容Windows(\r\n)和Linux(\n)格式)
* 2. 替换为单个换行,保留有效换行结构
* 3. 去除文本首尾的多余换行和空格
*/
function removeExtraLineBreaks(text) {
if (!text) return '';
// 第一步:将Windows格式换行(\r\n)统一转为Linux格式(\n),避免格式混乱
const unifiedText = text.replace(/\r\n/g, '\n');
// 第二步:将连续多个换行(2个及以上)替换为单个换行
const processedText = unifiedText.replace(/\n{2,}/g, '\n');
// 第三步:去除文本首尾的换行和空白字符,返回最终结果
return processedText.trim();
}
// 绑定「去除多余换行」按钮事件
processBtn.addEventListener('click', function() {
const originalText = inputText.value;
const resultText = removeExtraLineBreaks(originalText);
outputText.value = resultText;
});
// 绑定「清空输入」按钮事件
clearInputBtn.addEventListener('click', function() {
inputText.value = '';
// 清空输入时可选择是否清空输出(按需调整,这里保留输出)
// outputText.value = '';
});
// 绑定「清空输出」按钮事件
clearOutputBtn.addEventListener('click', function() {
outputText.value = '';
});
// 绑定「复制输出结果」按钮事件
copyOutputBtn.addEventListener('click', async function() {
const outputValue = outputText.value;
if (!outputValue) {
alert('输出区域无内容可复制!');
return;
}
try {
// 调用浏览器剪贴板API复制文本
await navigator.clipboard.writeText(outputValue);
// 显示复制成功提示
copyTip.classList.add('show');
// 3秒后隐藏提示
setTimeout(() => {
copyTip.classList.remove('show');
}, 3000);
} catch (err) {
console.error('复制失败:', err);
alert('复制失败,请手动选中复制!');
}
});
// 可选:绑定回车快捷键(仅输入区聚焦时,按Ctrl+Enter触发处理)
inputText.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
processBtn.click();
}
});
</script>
</body>
</html>