在线手写数据集 CASIA-OLHWDB：联机手写识别的宝藏资源
CASIA简介CASIA是一个非常有价值的手写单字数据集。它涵盖了众多的汉字以及字母、数字和符号。由中科院自动化研究所在 2007-2010 年间收集；包含1020人书写的脱机（联机）手写中文单字样本和手写文本；联机数据是采用 Anoto 笔在点阵纸上书写后扫描、分割得到。主要由离线（HWDB）和在线（OLHWDB）两部分。具体可以前往官网了解：https://nlpr.ia.ac.cn/databases/handwriting/Home.html这里只介绍在线手写数据集部分，离线的网上介绍比较多。在线手写数据集单字主要包括OLHWDB1.0 与 OLHWDB1.2。一共收录汉字 7185 个，涵盖了 GB2312 中全部 6763 个汉字。这个数据集以.pot文件存储，其数据组织形式独特。这些版本的数据集可以用于训练和测试在线手写汉字识别模型。CASIA - OLHWDB 为手写汉字识别、字形分析等相关任务提供了丰富的数据资源。pot文件的解析pot文件的解析相关教程并不多，官网也只给出了文件头信息，和一个预览的软件。使用预览软件打开某个pot文件的结果：我这里按照文件头规则直接写了一版python的，会将在线的点集转换为json文件存储，方便后期使用，文件夹的命名使用的是这个汉字字符对应的md5编码，当然你也可以修改为其他的，解析时间很长，因为一个字符大概有100多个样本。import hashlib import json import os import struct def parse_pot_file(filename): with open(filename, 'rb') as f: while True: # 读取样本大小 sample_size_data = f.read(2) if not sample_size_data: break # 文件读取结束 sample_size = struct.unpack('<H', sample_size_data)[0] # 读取字符的 GBK 编码 tag_code_data = f.read(4) tag_code = struct.unpack('<I', tag_code_data)[0] gbk_code = tag_code & 0xFFFF # 提取低16位 character = gbk_code.to_bytes(2, 'big').decode('gbk', errors='ignore').strip('\x00') # print(f"Character: {character}") # c.append(character) # 读取笔画数量 stroke_count_data = f.read(2) stroke_count = struct.unpack('<H', stroke_count_data)[0] # print(f"Number of strokes: {stroke_count}") # 读取每个笔画 strokes = [] for _ in range(stroke_count): stroke = [] while True: # 读取一个坐标对 x, y = struct.unpack('<hh', f.read(4)) if (x, y) == (-1, 0): # 笔画结束标志 break stroke.append((x, y)) strokes.append(stroke) # 将字符编码为md5 md5_hash = hashlib.md5(character.encode('utf-8')).hexdigest() # 创建以MD5为名的文件夹 folder_path = os.path.join(output_dir, md5_hash) os.makedirs(folder_path, exist_ok=True) # 计算已有文件数量，确定当前文件名 existing_files = os.listdir(folder_path) json_filename = f"{len(existing_files) + 1}.json" # 将strokes保存为JSON文件 json_file_path = os.path.join(folder_path, json_filename) with open(json_file_path, 'w', encoding='utf-8') as json_file: json.dump(strokes, json_file, ensure_ascii=False, separators=(',', ':')) # print(f"Strokes: {strokes}") # 检查字符结束标志 (-1, -1) end_marker = struct.unpack('<hh', f.read(4)) if end_marker != (-1, -1): raise ValueError("Invalid character end marker.") output_dir = 'D:\\VeenCode\\MyCode\\DataSet\\OLHWDB\\PotTrainJson' input_dir = 'D:\\VeenCode\\MyCode\\DataSet\\OLHWDB\\Pot1.0Train' # 遍历文件夹中的所有 .pot 文件 for root, _, files in os.walk(input_dir): for file in files: if file.endswith('.pot'): file_path = os.path.join(root, file) parse_pot_file(file_path) print(os.path.join(root, file))文件夹Pot1.0Train用来存放全部pot文件，会将结果输出到PotTrainJson文件夹。结果预览将pot转换成json后，处理起来就方便太多了，随机来一首诗预览一下字符：下面是预览的代码，大家也可以拿去玩玩，使用了Tk库，在canvas上绘制的：import hashlib import json import tkinter as tk import os import random # 读取JSON文件并解析数据 def load_json(file_path): with open(file_path, 'r') as file: data = json.load(file) return data # 归一化点的坐标，放缩到90x90区域并居中 def normalize_data(strokes, offset_x, offset_y, word_size=90, box_size=100): # 获取所有点的最大值和最小值，以便归一化 all_x = [point[0] for stroke in strokes for point in stroke] all_y = [point[1] for stroke in strokes for point in stroke] min_x, max_x = min(all_x), max(all_x) min_y, max_y = min(all_y), max(all_y) # 归一化到90x90的绘制区域 scale_x = word_size / (max_x - min_x) if max_x - min_x != 0 else 1 scale_y = word_size / (max_y - min_y) if max_y - min_y != 0 else 1 # 计算居中的偏移量 center_x_offset = (box_size - word_size) / 2 center_y_offset = (box_size - word_size) / 2 normalized_strokes = [] for stroke in strokes: normalized_stroke = [] for x, y in stroke: norm_x = (x - min_x) * scale_x + offset_x + center_x_offset norm_y = (y - min_y) * scale_y + offset_y + center_y_offset normalized_stroke.append((norm_x, norm_y)) normalized_strokes.append(normalized_stroke) return normalized_strokes # 在canvas上绘制笔画 def draw_strokes(canvas, strokes): for i, stroke in enumerate(strokes): color = "red" if i == 0 else "black" for j in range(len(stroke) - 1): x1, y1 = stroke[j] x2, y2 = stroke[j + 1] canvas.create_line(x1, y1, x2, y2, fill=color, width=2) # 主函数：随机挑选10个JSON文件并渲染到画布上 def main(selected_files): # Canvas的大小设置为500x500 canvas_width = 500 canvas_height = 500 box_size = 100 # 每个字的框大小为100x100 word_size = 50 # 字的大小为90x90 # 创建Tkinter窗口和Canvas root = tk.Tk() root.title("Drawing Random Strokes") canvas = tk.Canvas(root, width=canvas_width, height=canvas_height) canvas.pack() # 按照5列2行的顺序排列和绘制笔画 num_cols = 5 for index, file_name in enumerate(selected_files): # 计算子区域的左上角的坐标 col = index % num_cols row = index // num_cols offset_x = col * box_size offset_y = row * box_size # 加载和归一化数据 strokes = load_json(os.path.join(folder_path, file_name)) normalized_strokes = normalize_data(strokes, offset_x, offset_y, word_size, box_size) # 在子区域内绘制笔画 draw_strokes(canvas, normalized_strokes) # 运行Tkinter主循环 root.mainloop() # 示例调用 if __name__ == "__main__": # 给定字符 chars = "白日依山尽黄河入海流欲穷千里目更上一层楼" json_path = [] # 遍历字符并生成对应的JSON文件路径 for char in chars: # 将字符md5加密 char_md5 = hashlib.md5(char.encode()).hexdigest() folder_path = os.path.join(r"D:\VeenCode\MyCode\DataSet\OLHWDB\PotTrainJson", char_md5) # 随机从folder_path文件夹中挑选一个json文件 json_files = [f for f in os.listdir(folder_path) if f.endswith('.json')] json_file = random.choice(json_files) json_path.append(os.path.join(folder_path, json_file)) main(json_path)
近日，在刷蓝桥杯中的模拟实战赛时，有这么一道题：叫“汽车私人定制”，题目链接如下。https://www.lanqiao.cn/problems/18456/learning/?contest_id=187 打开css的文件，可以看到代码都是类似scss这种： 最开始以为是使用了什么预编译的技术方案，后来才知道，原来原生浏览器已经开始支持css嵌套语法了，关键词是CSS Nesting。 2024年，可以看到基本上市面上大部分主流浏览器都支持CSS Nesting，也就是说这确确实实成为了一种既定的规范。语法/* Without nesting selector */ parent { /* parent styles */ child { /* child of parent styles */ } } /* With nesting selector */ parent { /* parent styles */ & child { /* child of parent styles */ } } /* the browser will parse both of these as */ parent { /* parent styles */ } parent child { /* child of parent styles */ } 可以看到语法规则和less、scss非常类似。了解更多可以参考MDN：https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting注意事项虽然语法很类似，但需要注意：Concatenation is not possible（无法像 less 或 sass 利用父选择器和其他字符串组合）比如下面这一段： tag p 预期结果：.css-nesting-concatenation .css-nesting-concatenation-p {color: blue;} 实际结果：找不到元素，未生效。 根据 MDN 解释，& 选择只会被当做 选择器 来使用Invalid nested style rules - （嵌套样式内 遇到无效样式的处理） 如何处理在嵌套模式内无效样式？看一下MDN 中，可以看出示例，在遇到无效样式时，会忽略无效样式，继续解析下一个有效样式。.parent { /* .parent styles these work fine */ & %invalid { /* %invalid styles all of which are ignored */ } & .valid { /* .parent .valid styles these work fine */ } } 但这里要注意，不同浏览器处理的结果可能会有不一致的情况，比如下面这个例子： tag i tag p tag a 在我本机的Chrome和Firefox上表现不一致，Firefox和MDN描述的是一致的。最后想说的 在web发展的过程中，有很多API都是浏览器吸收了开源社区的热门库后出现的，比如 document.querySelector 吸收了 jQuery 的精华，或者Promise，JSON的相关API都是先出现在社区，然后被浏览器原生支持。今天的css原生嵌套也是借鉴了less，sass等社区方案而出现的。这才是共同进步。
蓝桥杯十五届省赛Web应用开发题目答案解析
去年报了个蓝桥杯Web，昨天刚打完省赛，10道题基本无压力 ::(呵呵) （[DarkBText]果然是圈钱杯[/DarkBText]），最后一道可能会扣点分，整理一下赛后直播的答案，也包括职业院校组的，给需要的小伙伴们：1. 智能停车系统 ⭐️考点： flex 布局解题答案：.cars { position: absolute; z-index: 2; display: flex; width: 600px; height: 600px; flex-direction: column; /* TODO: 请在下方编写题目代码，不要更改其他选择器里的代码 */ flex-wrap: wrap; align-content:space-between; justify-content:space-between; }2. 分享点滴 ⭐️考察：对象的遍历，字符串基本操作答案1：function appendParamsToURL(url, params) { // 创建一个空字符串来存储参数字符串 let str = ""; // 遍历参数对象中的每个键 for (const key in params) { // 将键值对追加到字符串中 str += `${key}=${params[key]}&`; } // 移除字符串末尾的 "&" 符号 str = str.slice(0, -1); // 判断是否原始 URL 已包含查询字符串（通过是否包含 "?" 符号来判断） // 如果已包含查询字符串，则设置 sym 变量为 "&"，用于以 "&" 作为分隔符追加参数 // 否则，设置 sym 变量为 "?"，作为查询字符串的起始符号 let sym = url.includes("?") ? "&" : "?"; // 连接原始 URL、sym 符号和参数字符串，并返回最终的 URL return `${url}${sym}${str}`; }答案2：function appendParamsToURL(url, params) { // 使用 Object.entries() 获取参数对象的所有键值对并按照规则拼接 const paramArray = Object.entries(params).map( ([key, value]) => `${key}=${value}` ); // 使用join方法拼接参数字符串 const paramString = paramArray.join("&"); // 判断URL中是否已包含参数 let sym = url.includes("?") ? "&" : "?"; // 返回拼接后的URL return `${url}${sym}${paramString}`; }3. 布局切换 ⭐️考察 DOM 操作、class 类名操作 option.addEventListener('click', function () { for (const otherOption of layoutOptions) { // 移除其他选项的 active 类 otherOption.classList.remove('active'); } // TODO：待补充代码 this.classList.add('active'); // 为当前选项添加active类 }4. 产品 360 度展示 ⭐️⭐️考察：Promise、循环答案1：const pipeline = async (initialValue, sequence) => { let res = initialValue; for (const fn of sequence) { res = await fn(res); } return res; };答案2：const pipeline = (initialValue, sequence) => { let res = Promise.resolve(initialValue); for (const fn of sequence) { res = res.then((result) => fn(result)); } return res; };5. 多表单验证 ⭐️考察element-plus组件的使用const validateName = (rule, value, callback) => { if (value === "") { callback(new Error("请输入姓名")); } else { const reg = /[^\u4e00-\u9fa5]/g; if (reg.test(value)) { callback(new Error("只能输入汉字")); } callback(); } }; // TODO-3：书写表单校验规则，并绑定到对应表单上 const rules = reactive({ name: [ { required: true, message: "请输入姓名", trigger: "blur" }, { validator: validateName, trigger: "blur" }, ], sex: [{ required: true, message: "请选择性别", trigger: "change" }], age: [{ required: true, message: "请输入年龄", trigger: "blur" }], isCompetition: [ { required: true, message: "请选择是否参加过编程比赛", trigger: "change" }, ], isEntrepreneurship: [ { required: true, message: "请选择是否有过创业经历", trigger: "change" }, ], });6. 找回连接的奇幻之旅 ⭐️⭐️⭐️考察：函数封装、闭包function resetableOnce(fn) { let done = false; // 标记函数是否已执行 let result; // 保存函数执行的结果 /** * 保证只执行一次的函数 * @returns {Any} 返回函数fn的执行结果 */ function runOnce() { if (!done) { // 如果之前没有执行过，则执行函数fn，并保存结果 result = fn.apply(this, arguments); done = true; // 标记为已完成，确保之后不再执行 } // 返回之前执行的结果 return result; } /** * 重置函数的执行状态 */ function reset() { done = false; // 重置done为false，允许函数再次执行 } // 返回控制对象 return { runOnce, reset }; }7. 悠然画境 ⭐️⭐️考察数组操作、字符串操作function generateAndDisplayImages(imageCount, selectedText) { // TODO：待补充代码 根据数量和用户输入在页面上生成图片并显示到 .right-panel 元素中 let count = 0; artDataArray.forEach((el) => { let tags = el.tags.split("、"); tags.forEach((tag) => { if (selectedText.includes(tag)) { count++; } }); el["count"] = count; count = 0; }); artDataArray.sort((a, b) => { return b.count - a.count; // 从小到大 -> a.age - b.age }); let b = artDataArray.slice(0, imageCount); return b; }8. 简易 JSX 解析器 ⭐️⭐️考察 dom 操作、字符串方法/** * 将驼峰式命名转换为短横线命名 * @param {string} str - 驼峰式命名的字符串 * @returns {string} - 转换后的短横线命名字符串 */ function camelToDash(str) { let result = ""; let hasUppercase = false; // 标记字符串中是否包含大写字母 for (let i = 0; i < str.length; i++) { let char = str.charAt(i); // 检查当前字符是否为大写字母 if (char === char.toUpperCase()) { hasUppercase = true; if (i > 0) { result += "-"; // 在大写字母前添加短横线 } result += char.toLowerCase(); // 将大写字母转换为小写字母并添加到结果中 } else { result += char; // 如果不是大写字母，直接添加到结果中 } } // 如果字符串中没有大写字母，则直接返回原字符串 if (!hasUppercase) { return str; } return result; } const jsx = (type, config) => { /* TODO: 请完善 jsx 函数的代码实现 */ const dom = document.createElement(type); for (let key in config) { const prop = config[key]; if (key === "style") { // 处理样式 let styleStr = Object.entries(prop) .map((item) => { return `${camelToDash(item[0])}:${item[1]}`; }) .join(";"); dom.setAttribute("style", styleStr); } else if (typeof prop === "function") { dom[key] = prop; } else if (key === "children") { // 处理 children if (typeof prop === "string") { dom.appendChild(document.createTextNode(prop)); } else { prop.forEach((child) => typeof child === "string" ? dom.appendChild(document.createTextNode(child)) : dom.appendChild(child) ); } } else { // 处理其他属性 dom.setAttribute(key, prop); } } return dom; };9. tree 命令助手 ⭐️⭐️⭐️考察 fs、递归函数function generateTree(dirPath) { // 读取目录下的所有文件和文件夹 const files = fs.readdirSync(dirPath); const tree = []; files.forEach(file => { const filePath = path.join(dirPath, file); const isDirectory = fs.statSync(filePath).isDirectory(); if (isDirectory) { // 如果是目录，则递归生成子目录的文件树 const subtree = generateTree(filePath); tree.push({ name: file, children: subtree }); } else { // 如果是文件，则直接添加到文件树 tree.push({ name: file }); } }); return tree; }10. Github 明星项目统计 ⭐️⭐️⭐️考察 vue3 v-for、Echarts 数据处理 {{language}} 11. 小蓝驿站 ⭐️⭐️⭐️考察 vue3 v-for、ref、数据处理// 目标 1 0" class="contacts-group-title"> {{ group.letter }} {{ contact.name }} 删除 12. 商品浏览足迹 ⭐️⭐️⭐️考察 axios 请求、数据处理window.onload = async ()=> { const res = await axios("./js/data.json"); const newData = getData(res.data); showData(newData); }; /** * 将同一天浏览的相同商品去重并作为数组返回 * @param {Array} defaultData json 文件中读取到的原始数据 * @returns 去重后的数据，数据结构与 defaultData 相同 */ const removeDuplicates = defaultData => { let newData = []; // TODO：在下面补充代码，最终完成该函数封装 defaultData.forEach((defaultGoods) => { const defaultDate = new Date(defaultGoods.viewed_on); const dD = `${defaultDate.getFullYear()}-${ defaultDate.getMonth() + 1 }-${defaultDate.getDate()}`; defaultGoods.date = dD; let hadThisGoods = false; newData.forEach((tmpGoods) => { const tmpDate = new Date(tmpGoods.viewed_on); const tD = `${tmpDate.getFullYear()}-${ tmpDate.getMonth() + 1 }-${tmpDate.getDate()}`; if (tmpGoods.id == defaultGoods.id && tD == dD) { hadThisGoods = true; } }); if (!hadThisGoods) { newData.push(defaultGoods); } }); return newData; } /** * 将去重后的数据根据字段 viewed_on（格式化为 YYYY-MM-DD） 降序排序 * @param {*} defaultData 去重后的数据 * @returns 根据字段 viewed_on（格式化为 YYYY-MM-DD） 降序排序 */ const sortByDate = defaultData => { let newData = []; // TODO：在下面补充代码，最终完成该函数封装 function sortByProperty(property, asc) { return function (value1, value2) { let a = new Date(value1[property]); let b = new Date(value2[property]); // 默认升序 if (asc == undefined) { return a - b; } else { return asc ? a - b : b - a; } }; } newData = defaultData.sort(sortByProperty("date", false)); return newData; } /** * 将去重排序后的所有商品数据，作为一个对象存储并返回， * 该对象的所有 `key` 为浏览商品的当天日期（即，字段 viewed_on 格式化为 YYYY-MM-DD）， * `value` 为当天浏览的所有商品（以数组形式表现）。 * @param {Array} defaultData 重后的所有商品数据 * @returns */ const transformStructure = defaultData => { let newData = {}; // TODO：在下面补充代码，最终完成该函数封装 // 月份和日期 不足10的前面补0 defaultData.forEach(item=>{ let date=new Date(item.date) ; let y = date.getFullYear() let m = date.getMonth()+1 m=m<10?'0'+m:m let d = date.getDate(); d=d<10?'0'+d:d item.date=`${y}-${m}-${d}`; }) newData = defaultData.reduce((aD, item) => { console.log(item); if (!aD[item.date]) { aD[item.date] = []; aD[item.date].push(item); } else { if (!aD[item.date].includes(item.date)) { aD[item.date].push(item); } } return aD; }, {}); return newData; }13. 扫雷游戏 ⭐️⭐️⭐️⭐️考察二维数组操作、递归function mineSweeperAlgorithms(arr, { row, col }) { if (flagData[row][col]) return; flagData[row][col] = true; const { positionWithoutMineArr, count } = getAroundAndCount(arr, { row, col, }); if (count == 0) { // 如果当前格子周围没有地雷，递归执行扫雷算法 positionWithoutMineArr.forEach((around) => { mineSweeperAlgorithms(arr, around); }); } }14.NPM Download Simulator ⭐️⭐️⭐️⭐️考察：获取数据类型、 promise.racefunction myRace(iterable) { return new Promise((resolve, reject) => { if (!(Symbol.iterator in Object(iterable))) { reject( new TypeError( `${Object.prototype.toString .call({ iterable }) .slice(-8, -1) .toLocaleLowerCase()} is not iterable` ) ); return; } const promises = Array.from(iterable); let resolvedOrRejected = false; for (const promise of promises) { Promise.resolve(promise).then( (value) => { if (!resolvedOrRejected) { resolvedOrRejected = true; resolve(value); } }, (reason) => { if (!resolvedOrRejected) { resolvedOrRejected = true; reject(reason); } } ); } }); }当然，web只要最终实现的效果和题目中描述的一致，那就能得分，上面答案只是参考。总体感觉难度不大，希望能进一波国赛吧
手撕BP神经网络之参数更新数学推导
[RainBowText]揭开这个神秘的面纱，探索其中的数学奥秘[/RainBowText]BP神经网络概念 首先从名称中可以看出，BP神经网络可以分为两个部分，BP和神经网络。BP是Back Propagation的简写 ，意思是反向传播。BP网络能学习和存贮大量的输入-输出模式映射关系，而无需事前揭示描述这种映射关系的数学方程。它的学习规则是使用最速下降法也称为梯度下降法，通过反向传播来不断调整网络的权值和阈值，使网络的误差平方和最小。 其主要的特点是：信号是正向传播的，而误差是反向传播的。 举一个例子，某厂商生产一种产品，投放到市场之后得到了消费者的反馈，根据消费者的反馈，厂商对产品进一步升级，优化，一直循环往复，直到实现最终目的——生产出让消费者更满意的产品。产品投放就是“信号前向传播”，消费者的反馈就是“误差反向传播”。这就是BP神经网络的核心。[card title="🚨有几点需要注意的" color="warning"]一般在提到有多少层时，不包括输出层，只计算隐藏层和输出层在一起的数量一般来说，越深的网络越能学到更深的特征逻辑回归（Logistic）其实是神经网络的一个特例，即不包含隐藏层的神经网络[/card]训练一个BP神经网络的核心步骤 这张图基本反应了训练一个BP神经网络的核心步骤，很容易懂，不过多解释了。下面，我们以一个最简单的BP神经网络为例，来推导其计算过程。见下图：前向传播隐含层输入节点：$$ \begin{aligned} h_{1}=W_{11}\cdot x_{1}+W_{21}\cdot x_{2}+b_{h1}\\ h_{2}=W_{12}\cdot x_{2}+W_{22}\cdot x_{2}+b_{h2}\\ h_{3}=W_{13}\cdot x_{3}+W_{23}\cdot x_{2}+b_{h3} \end{aligned} $$隐含层输出节点：经过激活函数$f\left( x\right) =\dfrac{1}{1+e^{-x}}$运算得到：$$ \begin{aligned} h_{o1}=f\left( h_{1}\right) \\ h_{o2}=f\left( h_{2}\right) \\ h_{o3}=f\left( h_{3}\right) \\ \end{aligned} $$这里提一点激活函数的导数，后面会用到：$$ f^{'}(x)=\frac{0-(-e^{-x})}{(1+e^{-x})^{2}}=\frac{1}{1+e^{-x}}\times (1-\frac{1}{1+e^{-x}})=f(x)(1-f(x)) $$输出层输入节点：$$ \begin{aligned} y_{1}=W_{ho1}\cdot h_{o1}+W_{ho2}\cdot h_{o2}+W_{ho3}\cdot h_{o3}+b_{o} \end{aligned} $$输出层输出节点：$$ y_{o}=f\left( y_{1}\right) $$反向传播需要明白微积分中的链式法则，设$x$是实数，$f$和$g$是从实数映射到实数的函数。假设$y=g(x)$并且$z=f(g(x))=f(y)$。那么链式法则就是：$$ \frac{dz}{dx}=\frac{dz}{dy}\frac{dy}{dx} $$在算法中，会计算实际输出$y_{o}$和期望输出$d_{o}$的误差，如果这个误差大于设定的阈值，那么就会进行误差的反向传播。定义损失函数：$$ f_{error}=\dfrac{1}{2}\sum \left( d_{o}-y_{o}\right) ^{2} $$损失函数中的$1/2$是为了后面求导计算方便而添加的。🎯目标：更新$W$、$b$使$f_{error}$更小输出层→隐藏层的更新先看输出层，假设更新后的$b_{o}$为$b_{o}^{'}$，即$b_{o}^{'} = b_{o}+\Delta b_{o}$，只要求出$\Delta b_{o}$，就能得到$b_{o}^{'}$。根据梯度下降的原则，可以知道：$$ \Delta b_{o}=\eta \cdot \dfrac{\partial f_{error}}{\partial b_{o}} $$根据上面的推导，可以知道：$$ f_{error}=\dfrac{1}{2} \left( d_{o}-y_{o}\right) ^{2}=\dfrac{1}{2} (d_{o}-f(W_{ho1}\cdot h_{o1}+W_{ho2}\cdot h_{o2}+W_{ho3}\cdot h_{o3}+b_{o}))^2 $$根据链式法则：$$ \dfrac{\partial f_{error}}{\partial b_{o}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial b_{o}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial y_{1}}\cdot \dfrac{\partial y_{1}}{\partial b_{o}} $$下面单独求解各个偏导：$$ \dfrac{\partial f_{error}}{\partial y_{o}}=\dfrac{\partial (\dfrac{1}{2} \left( d_{o}-y_{o}\right) ^{2})}{\partial y_{o}}=-(d_{o}-y_{o}) $$$$ \dfrac{\partial y_{o}}{\partial y_{1}}=\dfrac{\partial (\dfrac{1}{1+e^{-y_{1}}})}{\partial y_{1}}=y_{o}(1-y_{o}) $$$$ \dfrac{\partial y_{1}}{\partial b_{o}}=\dfrac{\partial (W_{ho1}\cdot h_{o1}+W_{ho2}\cdot h_{o2}+W_{ho3}\cdot h_{o3}+b_{o})}{\partial b_{o}}=1 $$所以：$$ \dfrac{\partial f_{error}}{\partial b_{o}}=-(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o}) $$所以：$$ \Delta b_{o}=\eta \cdot \dfrac{\partial f_{error}}{\partial b_{o}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o}) $$对于$W$，这里以$W_{ho1}$为例子，其他同理可得。由链式法则：$$ \dfrac{\partial f_{error}}{\partial W_{ho1}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial W_{ho1}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial y_{1}}\cdot \dfrac{\partial y_{1}}{\partial W_{ho1}} $$式中的$\dfrac{\partial f_{error}}{\partial y_{o}}$和$\dfrac{\partial y_{o}}{\partial y_{1}}$已经计算过，无需再次计算。$$ \dfrac{\partial y_{1}}{\partial W_{ho1}} = \dfrac{\partial (W_{ho1}\cdot h_{o1}+W_{ho2}\cdot h_{o2}+W_{ho3}\cdot h_{o3}+b_{o})}{\partial W_{ho1}}=h_{o1} $$所以：$$ \Delta W_{ho1}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{ho1}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot h_{o1} $$这样就把输出层的$W$、$b$更新完成了，更新后的结果：$$ b_{o}^{'} = b_{o}+\Delta b_{o}=b_{o}-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o}) $$$$ \begin{aligned} W_{oh1}^{'} = W_{oh1}+\Delta W_{oh1}=W_{oh1}-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot h_{o1}\\ W_{oh2}^{'} = W_{oh1}+\Delta W_{oh2}=W_{oh2}-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot h_{o2}\\ W_{oh3}^{'} = W_{oh1}+\Delta W_{oh3}=W_{oh3}-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot h_{o3}\\ \end{aligned} $$隐藏层→输入层的更新这里以$b_{h1}$为例子。此时：$$ \begin{aligned} f_{\text{error}} &= \frac{1}{2} \left( d_{o} - y_{o} \right)^{2} \\ &= \frac{1}{2} \left( d_{o} - f \left( W_{ho1} \cdot f \left( x_{1} \cdot W_{11} + x_{2} \cdot W_{21} + b_{h1} \right) + W_{ho2} \cdot f \left( x_{1} \cdot W_{12} + x_{2} \cdot W_{22} + b_{h2} \right) \right. \right. \\ &\quad \left. \left. + W_{ho3} \cdot f \left( x_{1} \cdot W_{13} + x_{2} \cdot W_{23} + b_{h3} \right) + b_{o} \right) \right)^{2} \end{aligned} $$由链式法则：$$ \dfrac{\partial f_{error}}{\partial b_{h1}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial b_{h1}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial y_{1}}\cdot \dfrac{\partial y_{1}}{\partial h_{o1}}\cdot \dfrac{\partial h_{o1}}{\partial b_{h1}} $$式中的$\dfrac{\partial f_{error}}{\partial y_{o}}$和$\dfrac{\partial y_{o}}{\partial y_{1}}$已经计算过，无需再次计算。$$ \dfrac{\partial y_{1}}{\partial h_{o1}}=\dfrac{\partial (W_{ho1}\cdot h_{o1}+W_{ho2}\cdot h_{o2}+W_{ho3}\cdot h_{o3}+b_{o})}{\partial h_{o1}}=W_{ho1} $$$$ \dfrac{\partial h_{o1}}{\partial b_{h1}}=\dfrac{\partial(f(W_{11}\cdot x_{1}+W_{21}\cdot x_{2}+b_{h1}))}{\partial b_{h1}}=h_{o1}(1-h_{o1}) $$所以：$$ \Delta b_{h1}=\eta \cdot \dfrac{\partial f_{error}}{\partial b_{h1}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho1} \cdot h_{o1}(1-h_{o1}) $$同理，可以得到：$$ \Delta b_{h2}=\eta \cdot \dfrac{\partial f_{error}}{\partial b_{h2}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho2} \cdot h_{o2}(1-h_{o2}) $$$$ \Delta b_{h3}=\eta \cdot \dfrac{\partial f_{error}}{\partial b_{h3}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho3} \cdot h_{o2}(1-h_{o3}) $$对于$W$，这里以$W_{11}$为例子，其他同理可得。由链式法则：$$ \dfrac{\partial f_{error}}{\partial W_{11}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial W_{ho1}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial h_{o1}}\cdot \dfrac{\partial h_{o1}}{\partial W_{11}}=\dfrac{\partial f_{error}}{\partial y_{o}}\cdot \dfrac{\partial y_{o}}{\partial y_{1}}\cdot\dfrac{\partial y_{1}}{\partial h_{o1}}\cdot \dfrac{\partial h_{o1}}{\partial W_{11}} $$式中的$\dfrac{\partial f_{error}}{\partial y_{o}}$、$\dfrac{\partial y_{o}}{\partial y_{1}}$和$\dfrac{\partial y_{1}}{\partial h_{o1}}$已经计算过，无需再次计算。$$ \dfrac{\partial h_{o1}}{\partial W_{11}}=\dfrac{\partial h_{o1}}{\partial h_{1}}\cdot \dfrac{\partial h_{1}}{\partial W_{11}}=h_{1}(1-h_{1})\cdot x_{1} $$所以：$$ \Delta W_{11}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{11}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho1} \cdot h_{1}(1-h_{1})\cdot x_{1} $$同理，可以得到：$$ \Delta W_{12}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{12}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho2} \cdot h_{2}(1-h_{2})\cdot x_{1} $$$$ \Delta W_{13}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{13}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho3} \cdot h_{3}(1-h_{3})\cdot x_{1} $$$$ \Delta W_{21}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{21}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho1} \cdot h_{1}(1-h_{1})\cdot x_{2} $$$$ \Delta W_{22}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{22}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho2} \cdot h_{2}(1-h_{2})\cdot x_{2} $$$$ \Delta W_{23}=\eta \cdot \dfrac{\partial f_{error}}{\partial W_{23}}=-\eta(d_{o}-y_{o})\cdot y_{o}\cdot (1-y_{o})\cdot W_{ho3} \cdot h_{3}(1-h_{3})\cdot x_{2} $$这样就把输入层的$W$、$b$更新完成了。下一篇开始手撕代码！
昨天在开发项目组项目时，有一个需求是将后端数据库返回的x、y点集渲染到前端。实际上是一个很简单的需求，使用前端的Canvas绘图就能完成，没想到调了大半天还没做出来，比例一直不对，遂放弃，回寝睡觉 ::(犀利) 。大概的意思就是像下面这种，绘图的比例会被拉伸：左边的图像就是昨天出现的“bug”，右侧的图像是正常的样子。在使用HTML5的Canvas元素时，内容或画布被拉伸的原因通常涉及到Canvas元素的大小与实际渲染尺寸之间的不匹配。今早顺着这条思路排查才发现Canvas绘图确实有个“坑点”。首先，Canvas元素会根据其属性中的宽度和高度进行初始化，Canvas 的默认大小为 300 像素 ×150 像素（宽 × 高，像素的单位是 px）。绘图操作会在这个初始化尺寸上进行，无论实际上屏幕上Canvas元素的显示尺寸是多少。绘制完成后，Canvas元素的内容会被放大或缩小到在CSS样式中设置的宽度和高度。如果CSS样式中的宽高与Canvas属性中的宽高不一致，就会导致内容在这个放大或缩小过程中发生变形。因为绘制是在Canvas属性指定的宽高上进行的，而放大或缩小是在绘制完成后的内容上进行的，所以这个过程可能导致图形的失真和比例不对。简单来说，如果在css中为canvas定义宽高，实际上把宽高为300px * 150px的画布进行了拉伸。应该在canvas属性中直接指定宽高。 或者也可以通过js直接指定宽高。const canvas = document.getElementById("myCanvas"); canvas.width=150; canvas.height=150;关于更多前端Canvas绘图教程可以参见MDN：https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial
项目组经过我和大师兄一个月的Coding，目前基本开发进度告一段落。可以沉下心来搞一搞机器学习相关的东西了。实际上，一开始进项目组的时候就想尝试采用机器学习（更具体的说是深度学习）的方式来做点不一样的事情，而不是纯数学的东西。当然，数学有数学的优势，机器学习有机器学习的优势。 扯得有点远，回到主题，这是最近学习CNN入门的时候练手用的，也应该算是我的第一个机器学习程序吧。记得做出来的时候，还是有点小小的激动，毕竟人生中第一次也不多，哈哈哈 ::(笑眼) 。中间也踩了不少坑，总结一下，整理成文，供后来者参考。CNN是什么 卷积神经网络（英语：convolutional neural network，缩写：CNN）是一种前馈神经网络，它的人工神经元可以响应一部分覆盖范围内的周围单元，对于大型图像处理有出色表现。 卷积神经网络由一个或多个卷积层和顶端的全连通层（对应经典的神经网络）组成，同时也包括关联权重和池化层（pooling layer）。这一结构使得卷积神经网络能够利用输入数据的二维结构。与其他深度学习结构相比，卷积神经网络在图像和语音识别方面能够给出更好的结果。这一模型也可以使用反向传播算法进行训练。相比较其他深度、前馈神经网络，卷积神经网络需要考量的参数更少，使之成为一种颇具吸引力的深度学习结构。 卷积神经网络的灵感来自于动物视觉皮层组织的神经连接方式。单个神经元只对有限区域内的刺激作出反应，不同神经元的感知区域相互重叠从而覆盖整个视野。 以上内容来自于维基百科 ，如果第一次接触的话，肯定是看的一脸懵。有机会我开个坑，尝试用大白话讲一讲。这里有个gif图，形象的展现了卷积的过程： MNIST数据集 MNIST数据集是一个手写数字识别数据集，常用于机器学习和深度学习的训练与测试。这个数据集包含了大量的手写数字图像，包括0到9的数字。这些图像都是28x28像素的灰度图像，每个图像都对应着一个标签，表示图像中所包含的数字。 MNIST手写数据集👉：官网 。官网中数据集一共分成了四个部分： 官网上提供了数据集的下载，主要包括四个文件：文件下载文件用途train-images-idx3-ubyte.gz训练集图像train-labels-idx1-ubyte.gz训练集标签t10k-images-idx3-ubyte.gz测试集图像t10k-labels-idx1-ubyte.gz测试集标签 在上述文件中，训练集一共包含了 60,000 张图像和标签，而测试集一共包含了 10,000 张图像和标签。测试集中前5000个来自最初NIST项目的训练集.，后5000个来自最初NIST项目的测试集。前5000个比后5000个要规整，这是因为前5000个数据来自于美国人口普查局的员工，而后5000个来自于大学生。 这里有一版整合了四个部分的数据集，推荐用这个，我已经上传至网盘，可以直接下载，我的代码也是基于这个整合后的数据集，下载解压即可✌️： 蓝奏云加载数据集 这里默认已经准备好所有环境，推荐使用Anaconda和PyCharm，新手友好。 加载数据集有两种方式，第一种是直接无需下载MNIST数据集，因为在Tensorflow中自带了，不过任然需要下载，且下载地址在海外，有可能会出现下载失败的情况，所以不推荐。代码如下，如果没有下载会自动下载：from keras.datasets import mnist (X_train, y_train), (X_test, y_test) = mnist.load_data() 下载的数据集路径位于：C:\Users\{用户名}\.keras。目前我还没有找到指定下载路径的方法，默认都放在了C盘下。 第二种方式是手动加载，把下载好的数据集文件放在项目目录下，编写加载数据集代码：# 加载Mnist数据集 def load_mnist(): with np.load('mnist.npz', allow_pickle=True) as data: return (data['x_train'], data['y_train']), (data['x_test'], data['y_test'])这里说明一下各个变量：x_train：训练集手写数据图像y_train：训练集手写数据图像对应的标签x_test：测试集手写数据图像y_test：测试集手写数据图像对应的标签我们可以通过下面函数的打印出形状：# 主函数 def main(): mnist = load_mnist() print_shape(mnist) # 打印形状 def print_shape(data): x_train, x_label = data[0] y_train, y_label = data[1] print(x_train.shape) print(x_label.shape) print(y_train.shape) print(y_label.shape)如果你和我一样，有这样的输出，那么就可以继续进行下一步了。预览数据集 上一步我们以及实现加载数据集，这里我们要看看这个数据集里的内容的真面貌。比如预览前25个图像，这里用到了matplotlib绘图库，如果没有安装，记得安装一下。import matplotlib.pyplot as plt # 主函数 def main(): mnist = load_mnist() view_image(mnist[0]) # 预览前25个图像 def view_image(data): x_train, y_train = data fig, ax = plt.subplots(nrows=5, ncols=5, sharex='all', sharey='all') ax = ax.flatten() for i in range(25): img = x_train[i].reshape(28, 28) ax[i].set_title(y_train[i]) ax[i].imshow(img, cmap='Greys', interpolation='nearest') ax[0].set_xticks([]) ax[0].set_yticks([]) plt.tight_layout() plt.show()预览结果：可以看到某些数字好像写的我自己都不认识，但是以标签为准，这也是机器学习的魅力。训练数据集并保存 这一步就是最核心的部分了。编写训练部分的代码，然后将训练好的模型保存下来，这样以后直接加载这个模型就可以了，不用再次训练。 由于MNIST数据集比较简单，我这里没有用到CNN卷积神经网络的卷积层和池化层，只进行了全连接层搭建。激活函数选择了relu，优化器选择Adam：# 训练并保存为SaveModel def train_model(mnist): x_train, y_train = mnist[0] x_test, y_test = mnist[1] # 归一化 x_train = x_train / 255.0 x_test = x_test / 255.0 model = keras.Sequential([ layers.Flatten(input_shape=(28, 28)), layers.Dense(128, activation='relu'), layers.Dense(10) ]) model.summary() model.compile(optimizer=keras.optimizers.Adam(), loss=losses.SparseCategoricalCrossentropy(from_logits=True), metrics=['accuracy']) model.fit(x_train, y_train, epochs=10) model.save('success') test_loss, test_acc = model.evaluate(x_test, y_test, verbose=2) print('\nTest accuracy:', test_acc) print('Test loss', test_loss) 等待10轮训练完成，可以看到loss在不断降低，accuracy在不断提高。最终可以达到97.7%的准确率 ::(真棒) 。 注意：这里都是通过CPU进行训练的，没有用到GPU，Tensorflow 2.10是Windows本地支持 GPU 的最后一个版本。从2.11开始，你就需要在WSL2上安装Tensorflow，或者使用TensorFlow-DirectML-Plugin。 我用的是2.15版本，不过好在这个数据集并不大，CPU已经完全够用了，整个过程不到20秒就训练结束。训练完成后，会把模型保存在项目文件夹下的success文件夹。加载模型并可视化验证结果 训练完毕后，我们可以加载保存好的模型，并进行验证。我这里就直接从测试集中随机选取10张图像来进行验证。有能力的小伙伴完全可以自己手写好数字，压缩成28*28的图像并用模型验证。# 加载模型并可视化验证结果 def load_model_check(mnist): matplotlib.rc("font", family='YouYuan') x_test1, y_test = mnist[1] # 归一化 x_test = x_test1 / 255.0 # 加载模型 model = keras.models.load_model('success') model.summary() plt.figure() for i in range(10): num = np.random.randint(1, 10000) plt.subplot(2, 5, i + 1) plt.axis('off') plt.imshow(x_test1[num], cmap='gray') demo = tf.reshape(x_test[num], (1, 28, 28)) y_pred = np.argmax(model.predict(demo)) plt.title('标签值：' + str(y_test[num]) + '\n预测值：' + str(y_pred)) plt.show() 注意，matplotlib库显示中文有可能会出现方框，记得指定一下字体（代码第二行），我这里就使用幼圆了。 可以看到预测结果还是很不错的，也给出了预测的时间，基本上都是在20ms范围内。总结 到此，Tensorflow2实现MNIST手写数据识别就结束了。有的人肯定会问我为啥不用pytorch，因为Tensorflow最大的优势是能够把模型迁移到各种位置，嵌入式、web都可以，而且还能够压缩模型，详情可看：https://tensorflow.google.cn/lite?hl=zh-cn。
项目组最近有一个实现自定义页面尺寸打印的需求： 浏览了网上大部分的教程，基本总结如下：方案一：CSS使用 CSS 的 @page 规则：通过 @page 规则，可以定义打印页面的尺寸和边距。例如，可以使用 @page 规则来定义 A4 纸张的尺寸和边距，或者根据需要定义其他自定义尺寸的页面。@page { size: 210mm 297mm; /* A4 尺寸 */ /* 或者自定义页面尺寸 */ }方案二：Javascript使用 JavaScript 来控制页面的打印参数，包括页面尺寸、边距等。可以通过 JavaScript 改变打印样式表中的参数，实现自定义页面尺寸的打印。function setPrintStyles() { const iframe ＝ document.createElement('iframe'); //插入需要打印的目标元素 document.querySelector(‘body‘).appendChild(iframe) //插入style ………… }方案三：利用浏览器自带打印使用打印预览对话框进行手动设置：在打印预览对话框中，用户可以手动选择页面尺寸、边距等打印参数，从而实现自定义页面尺寸的打印。 方案比较针对方案一，弊端在于部分低版本浏览器兼容性较差，不能很好的实现自定义打印效果。针对方案二，通过js来指定打印参数，麻烦在于需要将整个页面进行复制后（包括样式的复制），再进行打印，兼容性较好，但是代码多。针对方案三，不推荐，不同浏览器、不同操作系统会有不同的打印方案，难以控制保持一致性。最终解决方案：封装方案二最终决定封装一个javascript文件来实现自定义页面尺寸打印。核心部分包括：创建一个iframe标签，来容纳页面内容。复制页面中的原有style样式。包括head中的link stylesheet、style标签中的样式以及外部样式文件。打印完成后移除iframe标签。完整代码实现：const defaultOptions = { el: '', //打印目标dom节点 debug: true, //打开调试模式，会显示iframe, importCss: true, //引入head 中的link stylesheet importStyle: true, //引入style标签中的样式 loadCss: [], //需要载入的第三方样式表 title: '', //打印标题 delay: 300, //延迟打印时间，确保iframe中的静态资源加载完成 beforePrintHandle: null, //打开打印窗口前的钩子函数,可以针对打印文档进行自定义调整，接受一个document参数 afterPrintHandle: null, //打印完成的钩子函数 } let iframe = null let dom = null const checkOptions = options => { if(!options.el) { throw new Error('el must be a nodeType') } return { ...defaultOptions, ...options } } const printf = options => { const op = checkOptions(options) dom = op.el.cloneNode(true) const handle = createIframe(op) if(op.beforePrintHandle) { op.beforePrintHandle(handle.contentDocument); } if (op.afterPrintHandle) { op.afterPrintHandle(); } setTimeout(() => { handle.print(); if (op.debug === false) { removeIframe(); } }, op.delay) } const createIframe = (op) => { const { debug, importCss, importStyle, loadCss, title} = op removeIframe(); iframe = document.createElement('iframe'); if(debug === false) { iframe.style.display = 'none' } //插入需要打印的目标元素 document.querySelector('body').appendChild(iframe) iframe.contentDocument.title = title; const { body, head } = iframe.contentDocument; const contentWindow = iframe.contentWindow; //插入head中的link stylesheet if(importCss) { const stylesheets = document.querySelectorAll("link[rel = 'stylesheet']") stylesheets.forEach(item => { head.appendChild(item.cloneNode(true)) }) } //插入style if (importStyle) { const stylesheets = document.querySelectorAll("style") stylesheets.forEach(item => { body.appendChild(item.cloneNode(true)) }) } //插入外部样式文件 if (Array.isArray(loadCss) && loadCss.length > 0) { loadCss.forEach(item => { head.appendChild(item) }) } body.appendChild(dom) return contentWindow; } const removeIframe = () => { if (iframe) { document.querySelector('body').removeChild(iframe) iframe = null } } export default printf在调用的时候这样使用即可：// 打印事件处理 const printHandle = () => { const style = document.createElement('style'); style.innerHTML = `@media print { @page {size:${宽}mm ${高}mm!important; margin: 0;padding: 0;} }`; window.document.head.appendChild(style); printf({ el: printRef.value, //打印目标dom节点 debug: false, //打开调试模式，会显示iframe, importCss: true, //引入head 中的link stylesheet importStyle: true, //引入style标签中的样式 delay: 300, //延迟打印时间，确保iframe中的静态资源加载完成 }); }
在开发typecho主题的时候，typecho本身已经提供了封装好的设置项类，常见的例如有：Checkbox、Radio、Select、Text、Textarea等等。我们很方便的就能通过调用相关类来添加主题设置项，这也是最常见的一种方式。虽然够用，但这也带来了一些局限性，相比较于Wordpress，灵活度还是不够高。 为了解决上述问题，我们可以通过配合插件来从底层自己创建自定义的设置项页面。大致流程有：通过插件添加相关路由创建自定义的设置项表封装相关数据库语句创建全局变量以便于读取设置项 下面详细叙述各个流程：创建插件并添加路由 创建一个插件目录，例如"YourPlugin"，并在该目录下创建一个"Plugin.php"文件。接着就是添加插件基本方法以及需要继承的类，可以参考官方的HelloWorld插件来写。 在activate()方法中，使用Helper中的addRoute添加路由：const widgetName = 'TypechoPlugin\Furry\Router'; // 激活插件方法,如果激活失败,直接抛出异常 public static function activate() { Helper::addRoute('furry_options','/furry/options', self::widgetName, 'furryOptionsPageFunc'); Helper::addRoute('furry_options_api','/furry/api/options', self::widgetName, 'furryOptionsApiFunc'); } 这里我添加了/furry/options以及/furry/api/options两个路由。前者用来承接设置页的路由，后者用来加载数据库中的设置项，以json形式返回给前端。 记得在Action中实现furryOptionsPageFunc以及furryOptionsApiFunc两个方法，如果对于这一块不是很明白的可以参考Typecho的插件开发教程，网上有很多例子。这里我提供furryOptionsPageFunc的例子：// 渲染设置页 public function furryOptionsPageFunc() { if (User::alloc()->hasLogin()) { include_once 'options/index.php'; } else { Helper::options()->response->redirect('/admin/login.php'); } } 当用户登录时，访问/furry/options会加载options文件夹中的index.php。否则就会重定向到登录页面，这是一个简单的 鉴权。创建相关数据表 通过上面的分析，我们还知道，需要创建一张表，专门用来存放设置的数据，同样的，可以使用Typecho提供的数据库操作类来实现，对Typecho数据库Api不熟悉的可以参考我的另一篇文章： Typecho数据库常用API 。我这里给出我的例子：// 创建设置表 private static function createFurryOptionsTable() { $db = Db::get(); $prefix = $db->getPrefix(); $tableName = $prefix . 'furry_options'; if (!$db->fetchRow("SHOW TABLES LIKE '$tableName'")) { $db->query('CREATE TABLE `' . $tableName . '` ( `name` varchar(100) NOT NULL COMMENT \'设置名\', `type` varchar(20) NOT NULL COMMENT \'所属分类名\', `value` text COMMENT \'设置值\', PRIMARY KEY (`name`) )'); } } 在插件激活时，可以调用这个方法，就会创建一张表： 包括name设置名、type所属分类名、value设置值三个字段。这样，后面我们就可以存储自己的设置项了，即使切换主题也不会丢失，因为保存在了数据库中。初始化设置项 表有了，下一步就是往里面写入数据。这里我们可以用一个数组来作为初始化的数据。接着用循环语句通过insert方法来插入数据，我这里举个例子：// 初始化options数据 private static function initOptions() { $arr = [ // 全局设置 ["type" => "global", "name" => "AuthKey", "value" => ""], ["type" => "global", "name" => "BuildSiteTime", "value" => ""], ["type" => "global", "name" => "Favicon", "value" => ""], ["type" => "global", "name" => "ICP", "value" => ""], ["type" => "global", "name" => "Police", "value" => ""], // 旁侧边栏 ["type" => "sidebar", "name" => "LeftSide", "value" => "true"], ["type" => "sidebar", "name" => "RightSide", "value" => "true"], ]; $db = Db::get(); $prefix = $db->getPrefix(); $tableName = $prefix . 'furry_options'; foreach ($arr as $item) { $insert = $db->insert($tableName)->rows($item); $db->query($insert); } } 同样的，也是在插件激活的时候执行，就会得到初试的所有数据啦：查询设置项并在主题中使用 前三小节都是在插件中完成，这里，我们就需要返回主题文件夹中操作了。我们都知道，在Typecho中，themeInit函数是一个用于主题开发的钩子函数。它在每次加载主题时被调用，可以用来执行一些初始化操作和自定义功能。所以我们可以将这一步放入初始化函数中执行。也就是查询设置项并保存到全局变量中。 在functions.php中添加下面的函数来读取设置项，并定义一个全局变量用于保存：// 设置项全局变量 function furryThemeOptions() { $db = Db::get(); $prefix = $db->getPrefix(); $tableName = $prefix . 'furry_options'; $results = $db->fetchAll($db->select()->from($tableName)); $newArray = array(); foreach ($results as $item) { $newArray[$item['name']] = $item['value']; } @define('__FurryOptions__', $newArray); } 然后记得将这个函数在themeInit函数中调用。这样，在主题模板文件中，就可以这样使用： <?php echo __FurryOptions__['AuthKey'] ?> <?php $this->need('inc/left/left.php'); ?> 总结 至此，就完成了通过插件实现构建自定义设置页的教程，至于设置页的UI都可以通过自己的想法实现，甚至采用目前流行的前后端分离的方式去构建设置页。这样就能将现代前端的生态对接到typecho的管理当中。
Cuteen主题后台设置中的开关项是由Typecho的Checkbox改造的，但是在最新的typecho版本中出现了Checkbox无法勾选保存的现象，也就导致了无法开启主题相关功能，追查代码发现： 在typecho/var/Widget/Themes/Config.php中第74行位置有一句： $form->getInput($key)->value($this->options->{$key}); 上面这行代码中，从options中得到值，但是由于主题的复选框是新加入的，因此options中并没有对应的值，导致给了一个空的值给复选框。 当主题选项保存的时候，is_array中的数据是从$this->getInput('xxxx')拿到value，但前面程序已经设置为空值，所以在收集提交选项数据时候，以下代码判断不通过，返回null，最终导致保存勾选无效。$result[$param] = $request->get($param, is_array($this->getInput($param)->value) ? [] : null); 解决方案解决方案有三种：方案一 升级typecho到最新的开发版，在 #1590 中，此问题已修复方案二 回退typecho版本至 1.2.0，❌不推荐这种做法，小于1.2.1的版本中包含xss漏洞。方案三 打开typecho/var/Widget/Themes/Config.php，在第74行左右将其注释，按照下面代码修改： public function config(): Form { $form = new Form($this->security->getIndex('/action/themes-edit?config'), Form::POST_METHOD); themeConfig($form); $inputs = $form->getInputs(); if (!empty($inputs)) { foreach ($inputs as $key => $val) { //将这行注释： //$form->getInput($key)->value($this->options->{$key}); //改为这三行： if (isset($this->options->{$key})) { $form->getInput($key)->value($this->options->{$key}); } } } $submit = new Submit(null, null, _t('保存设置')); $submit->input->setAttribute('class', 'btn primary'); $form->addItem($submit); return $form; }
在最一开始的UI设计中，每个网站都有自己独特的设计风格，而这些设计风格可以被抽象成一套样式系统。其中最主要的样式系统代表就是Bootstrap。组件系统的出现使得我们不必再直接编写原始的CSS代码，而是可以直接使用预定义的CSS类，从而提高了开发效率。类似这样： Primary 但这明显有很多缺点，举个例子来说，比如你需要深度定制的组件的时候就不得不去覆盖样式，或者按照它本身提供的设计系统来设计。类似的还有Antd、ElementUI等等…… 于是你为了满足要求，开始自己手撸各种组件。接着你开始遇到了一些问题，比如代码变得冗长、复杂，难以理解，而且还出现了一些错误，比如在Safari或者IE上无法选择下拉项的问题、无障碍功能无法满足等等。最令人担忧的是可访问性问题，手工制作的组件无法被键盘用户和使用屏幕阅读器的人访问。于是你决定放弃，告诉自己：“好吧，果然还是Antd香，毕竟人们使用它是有原因的”。 那真的就没有办法解决这种痛点吗？无头CSS库就是个不错的选择。在前端深耕的小伙伴们应该对这个无头CSS库不陌生，广泛的定义是指一种不包含任何样式的UI组件库，它们只提供了结构和交互的逻辑，开发者可以根据自己的需求自由地定制样式。这种库的出现可以让开发者更加专注于业务逻辑和交互设计，同时也可以减少样式冲突和代码冗余。 一句话说就是：只有骨架和功能，而没有一丁点样式，你要做的就是给他们“穿衣服”。现在最流行的无头CSS库就是Headless UI，官网链接：https://headlessui.com/。 当然类似的还有，Angular cdk、Radix UI、React Aria 等。它提供可访问的组件，但没有样式——因此可以根据自己的喜好设置它们的样式。像这样，创建一个没有样式的下拉菜单：让我们简单加点CSS： 样式完全由你来决定！而且其组件在多种浏览器、平台和设备上经过了良好测试，并处理了可能永远无法或不想处理的边缘情况：诸如焦点管理、键盘导航、事件监听器、可访问性属性、有效标记和屏幕阅读器支持等内容。 这里有个开源地址，收集了目前的无头组件库，感兴趣的小伙伴可以留意一下：https://github.com/jxom/awesome-react-headless-components