随手造的实用小工具&项目合集

· · 科技·工程

:::align{center}

0x-1. 前言

:::

\Large\fcolorbox{#e1e1ff}{#9999ff}{\color{#d8d8ff}{本文中所有工具基本均在}\color{gold}{github}\color{#d8d8ff}{托管}}

可能更好的阅读体验

提供源代码直接复制,但是在文章这里不太方便吧。

如有访问困难可先在网上搜索 github 国内代理网站代替官网,实在不行可以考虑私信我解决。

我最近遇到一些小问题,比如拼接几个图片啦,画几个分子图啦,模糊一下图片啦,总之最后都是跟 copilot 聊了几句就完事了。

最近有人问我要这些,也为了方便大家,那我就放出来罢。

后续会更新。 :::align{center}

0x0. 环境配置

:::

基本所有项目均使用 Python。

咱先下个 Python,请在命令行依次全部输入

py --version
python --version
python3 --version

如果有任何一个返回形如 Python 3.<x>.<y> 并且 <x> 为大于等于 10 的整数,就说明你的环境初步好了。

如果没成功,可以访问Python 官网(这个国内太慢,但是若有一周天数根棍棍是可以 10 秒内下完的),点击 Download,然后点击页面中间偏左的金色大按钮就可以开始下载了。下载完打开包安装。装完了输入 python3 --version 测试一下。

着急也没有棍可以考虑 anaconda,虽然包很大,有 1GB,但是一两分钟就下下来了。下完了打开文件安装,遇到让你输入就输回车。安完了开个新的命令行窗口,前面会有一个 (base)。现在你的 Python 环境就配置好了。装完了输入 python --version 测试一下。

这里有许多高级选项,

下一步装包,每个小项目需要的包的安装代码会在后续给出。

特别提示:为了提升包下载速度(当然你有棍子可以不管),建议运行下列命令,设置镜像源:

python -m pip install --upgrade pip
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple

代码运行方式:命令行 <你测试可用的那个 py/python/python3> <文件路径> 别加尖括号哈

:::align{center}

0x1. 图片混淆器

:::

简介

这玩意功能很简单啊,也实用,适合用来【】CCF。

亲测对于笔迹效果极好,可以让人看出笔迹而 AI 不能,建议搭配画图软件或 OneNote 与一个好使的触控板或数位板。

依赖包安装

命令行运行以下代码:

pip install gradio opencv-python

代码

github 仓库

:::info[源代码]

import cv2
import numpy as np
import gradio as gr

def add_random_noise(img, noise_level=30):
    # img: numpy array, BGR
    noise = np.random.randint(-noise_level, noise_level + 1, img.shape, dtype='int16')
    noisy_img = img.astype('int16') + noise
    noisy_img = np.clip(noisy_img, 0, 255).astype('uint8')
    return noisy_img

def blur_edges(img, kernel_size=15):
    # 创建掩码,仅模糊边缘
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, 100, 200)
    mask = cv2.dilate(edges, None, iterations=3)
    mask = cv2.GaussianBlur(mask, (kernel_size, kernel_size), 0)
    mask = mask / 255.0

    blurred = cv2.GaussianBlur(img, (kernel_size, kernel_size), 0)
    mask = mask[..., np.newaxis]
    result = img * (1 - mask) + blurred * mask
    return result.astype('uint8')

def adjust_contrast(img, contrast):
    # contrast: 0.5~2.0,1.0为原始对比度
    img = img.astype(np.float32)
    img = (img - 127.5) * contrast + 127.5
    img = np.clip(img, 0, 255).astype(np.uint8)
    return img

def remove_border(img, tol=10):
    """
    自动去除图片四周的纯色边框(如白边、黑边等)。
    tol: 容差,越大越宽松。
    """
    if img.ndim == 3:
        mask = (np.abs(img - img[0,0]).sum(axis=2) > tol)
    else:
        mask = (np.abs(img - img[0,0]) > tol)
    coords = np.argwhere(mask)
    if coords.size == 0:
        return img  # 全图纯色
    y0, x0 = coords.min(axis=0)
    y1, x1 = coords.max(axis=0) + 1
    cropped = img[y0:y1, x0:x1]
    return cropped

def process_image(input_img, noise_level, kernel_size, contrast, remove_border_flag):
    img = cv2.cvtColor(input_img, cv2.COLOR_RGB2BGR)
    if remove_border_flag:
        img = remove_border(img)
    img = adjust_contrast(img, contrast)
    noisy_img = add_random_noise(img, noise_level)
    result_img = blur_edges(noisy_img, kernel_size)
    return cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB)

demo = gr.Interface(
    fn=process_image,
    inputs=[
        gr.Image(type="numpy", label="上传图片"),
        gr.Slider(0, 255, value=60, label="噪声强度"),
        gr.Slider(1, 51, value=15, step=2, label="模糊卷积核大小"),  # 起始值改为1
        gr.Slider(0.5, 2.0, value=1.0, step=0.05, label="对比度调整"),
        gr.Checkbox(label="去除图片边框等非图片本身信息")
    ],
    outputs=gr.Image(type="numpy", label="处理后图片"),
    title="图片随机噪声与边缘模糊工具",
    description="上传图片,插入随机噪声、模糊边缘、可调整对比度,并可自动去除图片边框等非图片本身信息。"
)

if __name__ == "__main__":
    demo.launch()

::: 运行一会后会弹出一个链接,在浏览器上打开它。 :::align{center}

0x2. 图片锐化器

:::

简介

这是一个本地应用,使用 PyQT5创建,可以提升图片锐度对比度。

真正的小应用 be like。

依赖包安装

命令行运行以下代码:

pip install PyQT5 opencv-python

代码

github 仓库

:::info[源代码]

import sys
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel, QFileDialog
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtCore import Qt

class ImageSharpener(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        self.image = None
        self.sharpened_image = None

    def initUI(self):
        self.setWindowTitle('Image Sharpener')
        layout = QVBoxLayout()

        self.imageLabel = QLabel(self)
        self.imageLabel.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.imageLabel)

        loadButton = QPushButton('Load Image', self)
        loadButton.clicked.connect(self.loadImage)
        layout.addWidget(loadButton)

        sharpenButton = QPushButton('Sharpen Image', self)
        sharpenButton.clicked.connect(self.sharpenImage)
        layout.addWidget(sharpenButton)

        saveButton = QPushButton('Save Image', self)
        saveButton.clicked.connect(self.saveImage)
        layout.addWidget(saveButton)

        self.setLayout(layout)
        self.resize(400, 400)

    def loadImage(self):
        fileName, _ = QFileDialog.getOpenFileName(self, "Open Image", "", "Image Files (*.png *.jpg *.bmp)")
        if fileName:
            self.image = cv2.imread(fileName)
            self.displayImage(self.image)
            self.sharpened_image = None

    def sharpenImage(self):
        if self.image is not None:
            kernel = np.array([[-1,-1,-1], [-1,9,-1], [-1,-1,-1]])
            self.sharpened_image = cv2.filter2D(self.image, -1, kernel)
            self.displayImage(self.sharpened_image)

    def saveImage(self):
        if self.sharpened_image is not None:
            fileName, _ = QFileDialog.getSaveFileName(self, "Save Image", "", "PNG (*.png);;JPEG (*.jpg *.jpeg);;All Files (*)")
            if fileName:
                cv2.imwrite(fileName, self.sharpened_image)

    def displayImage(self, img):
        qformat = QImage.Format_RGB888
        outImage = QImage(img.data, img.shape[1], img.shape[0], img.strides[0], qformat)
        outImage = outImage.rgbSwapped()
        pixmap = QPixmap.fromImage(outImage)
        self.imageLabel.setPixmap(pixmap.scaled(self.imageLabel.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = ImageSharpener()
    ex.show()
    sys.exit(app.exec_())

依赖包安装

命令行运行以下代码:

pip install gradio opencv-python

::: :::align{center}

0x3. 图片拼接小工具

:::

简介

结果,“在新标签页打开图片”截图:

依赖包安装

命令行运行以下代码:

pip install gradio opencv-python

代码

github 仓库

:::info[源代码]

import gradio as gr
from PIL import Image, UnidentifiedImageError
from typing import List, Union, Tuple
import re
import math

def hex_to_rgb(color) -> Tuple[int,int,int]:
    """兼容多种颜色输入:
       - '#rrggbb' 或 '#rgb'
       - 'rgb(r,g,b)' 或 'rgba(r,g,b,a)'
       - (r,g,b) 或 [r,g,b]
       返回 (r,g,b),解析失败时返回白色 (255,255,255)
    """
    if not color:
        return (255, 255, 255)
    # 已经是序列
    if isinstance(color, (tuple, list)):
        try:
            return tuple(int(max(0, min(255, int(c)))) for c in color[:3])
        except Exception:
            return (255, 255, 255)
    # 字符串处理
    if isinstance(color, str):
        s = color.strip()
        # 十六进制
        if s.startswith("#"):
            s = s.lstrip("#")
            if len(s) == 3:
                s = "".join([c*2 for c in s])
            if len(s) == 6:
                try:
                    return tuple(int(s[i:i+2], 16) for i in (0, 2, 4))
                except Exception:
                    return (255, 255, 255)
        # rgb(...) 或 rgba(...)
        if s.lower().startswith("rgb"):
            nums = re.findall(r"(-?\d+)", s)
            if len(nums) >= 3:
                try:
                    return tuple(min(255, max(0, int(n))) for n in nums[:3])
                except Exception:
                    return (255, 255, 255)
    # 不能解析时回退白色
    return (255, 255, 255)

def open_images(files: Union[List[str], str]):
    if not files:
        return []
    if isinstance(files, str):
        files = [files]
    images = []
    try:
        for fp in files:
            img = Image.open(fp).convert("RGB")
            images.append(img)
    except UnidentifiedImageError:
        raise ValueError("上传的文件中包含无法识别为图片的文件。")
    except Exception as e:
        raise ValueError(f"读取图片出错:{e}")
    return images

def resize_keep_aspect(img: Image.Image, target_w: int = None, target_h: int = None):
    w, h = img.size
    if target_w is None and target_h is None:
        return img
    if target_h is None:
        scale = target_w / w
    elif target_w is None:
        scale = target_h / h
    else:
        scale_w = target_w / w
        scale_h = target_h / h
        scale = min(scale_w, scale_h)
    new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
    return img.resize(new_size, Image.LANCZOS)

def layout_grid_metrics(images: List[Image.Image], cols: int, spacing: int):
    # images in list order -> fill rows left-to-right, top-to-bottom
    n = len(images)
    rows = math.ceil(n / cols)
    # compute column widths and row heights
    col_widths = [0] * cols
    row_heights = [0] * rows
    for idx, im in enumerate(images):
        r = idx // cols
        c = idx % cols
        col_widths[c] = max(col_widths[c], im.width)
        row_heights[r] = max(row_heights[r], im.height)
    total_w = sum(col_widths) + spacing * (cols - 1)
    total_h = sum(row_heights) + spacing * (rows - 1)
    return total_w, total_h, col_widths, row_heights

def render_grid(images: List[Image.Image], cols: int, spacing: int, bg_rgb: Tuple[int,int,int]):
    n = len(images)
    rows = math.ceil(n / cols)
    total_w, total_h, col_widths, row_heights = layout_grid_metrics(images, cols, spacing)
    out = Image.new("RGB", (total_w, total_h), bg_rgb)
    # compute x offsets for columns and y offsets for rows
    x_offsets = []
    x = 0
    for w in col_widths:
        x_offsets.append(x)
        x += w + spacing
    y_offsets = []
    y = 0
    for h in row_heights:
        y_offsets.append(y)
        y += h + spacing
    # paste each image centered in its cell
    for idx, im in enumerate(images):
        r = idx // cols
        c = idx % cols
        cell_x = x_offsets[c]
        cell_y = y_offsets[r]
        cell_w = col_widths[c]
        cell_h = row_heights[r]
        paste_x = cell_x + (cell_w - im.width) // 2
        paste_y = cell_y + (cell_h - im.height) // 2
        out.paste(im, (paste_x, paste_y))
    return out, total_w, total_h

def find_best_grid(images: List[Image.Image], spacing: int, bg_rgb: Tuple[int,int,int],
                   max_cols_search: int, objective: str, order_strategy: str):
    n = len(images)
    if max_cols_search <= 0:
        max_cols_search = n
    max_cols_search = min(max_cols_search, n)
    # generate different orderings to try
    def order_images(strategy):
        if strategy == "original":
            return images[:]
        if strategy == "width":
            return sorted(images, key=lambda im: im.width, reverse=True)
        if strategy == "height":
            return sorted(images, key=lambda im: im.height, reverse=True)
        if strategy == "area":
            return sorted(images, key=lambda im: im.width*im.height, reverse=True)
        return images[:]

    best = None  # tuple (metric_value, out_image, cols, w, h, order_name)
    strategies = [order_strategy] if order_strategy != "all" else ["original","width","height","area"]
    for cols in range(1, max_cols_search+1):
        for strat in strategies:
            seq = order_images(strat)
            out_img, w, h = render_grid(seq, cols, spacing, bg_rgb)[0:3]
            if objective == "area":
                metric = w * h
            elif objective == "maxside":
                metric = max(w, h)
            elif objective == "width":
                metric = w
            elif objective == "height":
                metric = h
            else:
                metric = w * h
            if best is None or metric < best[0]:
                best = (metric, out_img, cols, w, h, strat)
    return best

def concat_images(files: Union[List[str], str],
                  mode: str = "scale_height",
                  orientation: str = "horizontal",
                  spacing: int = 0,
                  bg_color: str = "#ffffff",
                  size_ref: str = "max",
                  auto_grid: bool = False,
                  max_grid_cols: int = 0,
                  grid_objective: str = "area",
                  grid_order: str = "height"):
    """
    新增参数:
      - auto_grid: 是否启用自动网格布局(多行多列)以尽量减小画布
      - max_grid_cols: 搜索最大列数(0 表示最大为图片数)
      - grid_objective: 'area' | 'maxside' | 'width' | 'height'
      - grid_order: 'original'|'width'|'height'|'area'|'all'
    """
    try:
        images = open_images(files)
    except ValueError as e:
        return str(e)

    if not images:
        return None

    # 先按已有模式处理每张图片(scale/fit/stretch/none)
    widths = [im.width for im in images]
    heights = [im.height for im in images]
    max_w, max_h = max(widths), max(heights)
    min_w, min_h = min(widths), min(heights)
    processed = []

    if mode == "none":
        if len(set(widths)) > 1 or len(set(heights)) > 1:
            return "所有图片尺寸必须一致,或选择其他处理模式。"
        processed = images

    elif mode == "scale_height":
        target_h = max_h if size_ref == "max" else min_h
        for im in images:
            processed.append(resize_keep_aspect(im, target_h=target_h))

    elif mode == "scale_width":
        target_w = max_w if size_ref == "max" else min_w
        for im in images:
            processed.append(resize_keep_aspect(im, target_w=target_w))

    elif mode == "fill":
        for im in images:
            w, h = im.size
            scale = max(max_w / w, max_h / h)
            new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
            resized = im.resize(new_size, Image.LANCZOS)
            canvas = Image.new("RGB", (max_w, max_h), hex_to_rgb(bg_color))
            offset_x = (max_w - resized.width) // 2
            offset_y = (max_h - resized.height) // 2
            canvas.paste(resized, (offset_x, offset_y))
            processed.append(canvas)

    elif mode == "stretch":
        for im in images:
            processed.append(im.resize((max_w, max_h), Image.LANCZOS))
    else:
        return "未知的处理模式。"

    # 如果启用自动网格布局 -> 在 processed 上搜索最优网格
    bg_rgb = hex_to_rgb(bg_color)
    if auto_grid:
        best = find_best_grid(processed, spacing, bg_rgb, max_grid_cols, grid_objective, grid_order)
        if best is None:
            return "无法生成网格布局。"
        _, out_img, best_cols, best_w, best_h, used_order = best
        return out_img

    # 否则,按横向或纵向线性拼接(保持原逻辑)
    widths = [im.width for im in processed]
    heights = [im.height for im in processed]
    n = len(processed)

    if orientation == "horizontal":
        total_w = sum(widths) + spacing * (n - 1)
        total_h = max(heights)
        out = Image.new("RGB", (total_w, total_h), bg_rgb)
        x = 0
        for im in processed:
            y = (total_h - im.height) // 2
            out.paste(im, (x, y))
            x += im.width + spacing
    else:
        total_w = max(widths)
        total_h = sum(heights) + spacing * (n - 1)
        out = Image.new("RGB", (total_w, total_h), bg_rgb)
        y = 0
        for im in processed:
            x = (total_w - im.width) // 2
            out.paste(im, (x, y))
            y += im.height + spacing

    return out

with gr.Blocks() as demo:
    gr.Markdown("# 图片拼接小工具(支持不同尺寸 & 自动网格布局)\n上传多张图片,选择处理模式与拼接方式。")
    with gr.Row():
        file_input = gr.File(label="上传图片", file_count="multiple", type="filepath")
        mode = gr.Radio(choices=[
            ("等比缩放到相同高度", "scale_height"),
            ("等比缩放到相同宽度", "scale_width"),
            ("等比缩放并填充到最大尺寸", "fill"),
            ("拉伸到相同尺寸(可能变形)", "stretch"),
            ("不处理(要求相同尺寸)", "none")
        ], value="scale_height", label="处理模式")
    with gr.Row():
        size_ref = gr.Radio(choices=[("以最大尺寸为准", "max"), ("以最小尺寸为准", "min")], value="max", label="参考尺寸(用于缩放)")
        orientation = gr.Radio(choices=[("横向拼接", "horizontal"), ("纵向拼接", "vertical")], value="horizontal", label="拼接方向(线性)")
    with gr.Row():
        spacing = gr.Slider(minimum=0, maximum=200, value=0, step=1, label="图片间距(像素)")
        bg_color = gr.ColorPicker(value="#ffffff", label="背景颜色")
    with gr.Row():
        auto_grid = gr.Checkbox(label="启用自动网格布局(让画布尽量小)", value=False)
        max_grid_cols = gr.Slider(minimum=0, maximum=20, step=1, value=0, label="网格最大列数(0=自动到图片数)")
    with gr.Row():
        grid_objective = gr.Radio(choices=[
            ("最小面积", "area"),
            ("最小最大边(minimize max(width,height))", "maxside"),
            ("最小宽度", "width"),
            ("最小高度", "height")
        ], value="area", label="网格优化目标")
        grid_order = gr.Radio(choices=[
            ("只用当前顺序", "original"),
            ("按宽降序", "width"),
            ("按高降序", "height"),
            ("按面积降序", "area"),
            ("全部策略尝试", "all")
        ], value="height", label="排序策略")
    output = gr.Image(type="pil", label="拼接结果")
    btn = gr.Button("开始拼接")
    btn.click(fn=concat_images, inputs=[file_input, mode, orientation, spacing, bg_color, size_ref, auto_grid, max_grid_cols, grid_objective, grid_order], outputs=output)

if __name__ == "__main__":
    demo.launch()

::: :::align{center}

0x4. 题解创建器

:::

简介

又一个迷你应用。输入题号格式和主站“题目跳转”格式相同(输入数字直接跳转 P 题库)

依赖包安装

命令行运行以下代码:

pip install gradio opencv-python

代码

github 仓库

:::info[源代码]

import gradio as gr

def f(s):
    if '0' <= s[0] <= '9':
        s = 'P' + s
    return f"[https://www.luogu.com.cn/article/_new?problem={s}](https://www.luogu.com.cn/article/_new?problem={s})"

with gr.Blocks() as app:
    id = gr.Textbox(label="洛谷题目编号")
    sub = gr.Button("生成题解模版")
    out = gr.Markdown("")
    sub.click(f, id, outputs=out)

app.launch()

::: :::align{center}

0x5. 分子画图器

:::

简介

很实用的分子图绘画器,输入 SMILES 代码,输出分子图,还提供 SVG 下载和存档!

关于 SMILES 代码,可以参考官方文档或者百度搜索。

请将代码放在专用目录下运行,因为这个有存档

依赖包安装

命令行运行以下代码:

pip install gradio rdkit pillow

代码

请将代码放在专用目录下运行,因为这个有存档

github 仓库

:::info[源代码]

import os
import io
import csv
import uuid
from datetime import datetime

from rdkit import Chem
from rdkit.Chem import Draw
# try multiple import paths for rdMolDraw2D for RDKit distribution differences
try:
    from rdkit.Chem import rdMolDraw2D  # common in some builds
except Exception:
    try:
        from rdkit.Chem.Draw import rdMolDraw2D  # alternative location
    except Exception:
        # last attempt: maybe accessible as attribute
        rdMolDraw2D = getattr(Draw, "rdMolDraw2D", None)

from PIL import Image

import gradio as gr

# Paths
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
IMAGES_DIR = os.path.join(ROOT_DIR, "images")
ARCHIVE_PATH = os.path.join(ROOT_DIR, "smiles_archive.csv")

os.makedirs(IMAGES_DIR, exist_ok=True)

# Ensure CSV has full header (id,timestamp,smiles,saved_image,notes)
if not os.path.exists(ARCHIVE_PATH):
    with open(ARCHIVE_PATH, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["id", "timestamp", "smiles", "saved_image", "notes"])

def mol_image_from_smiles(smiles: str, width: int = 300, height: int = 300):
    if not smiles or not smiles.strip():
        return None, "Empty SMILES input."
    mol = Chem.MolFromSmiles(smiles.strip())
    if mol is None:
        return None, "无法解析 SMILES(无效格式)."
    try:
        img = Draw.MolToImage(mol, size=(int(width), int(height)))
        if not isinstance(img, Image.Image):
            img = Image.fromarray(img)
        return img, None
    except Exception as e:
        return None, f"绘制分子时出错: {e}"

def mol_svg_from_smiles(smiles: str, width: int = 300, height: int = 300):
    if not smiles or not smiles.strip():
        return None, "Empty SMILES input."
    mol = Chem.MolFromSmiles(smiles.strip())
    if mol is None:
        return None, "无法解析 SMILES(无效格式)."
    if rdMolDraw2D is None:
        # rdMolDraw2D not available in this RDKit build
        return None, ("当前 RDKit 构建中缺少 rdMolDraw2D(无法生成 SVG)。"
                      " 建议使用 conda 安装 RDKit(例如:conda install -c conda-forge rdkit),"
                      " 或选择 PNG 输出格式。")
    try:
        drawer = rdMolDraw2D.MolDraw2DSVG(int(width), int(height))
        rdMolDraw2D.PrepareAndDrawMolecule(drawer, mol)
        drawer.FinishDrawing()
        svg = drawer.GetDrawingText()
        return svg, None
    except Exception as e:
        return None, f"生成 SVG 时出错: {e}"

def handle_draw(smiles, width, height, fmt):
    if fmt == "SVG":
        svg, err = mol_svg_from_smiles(smiles, width, height)
        if err:
            return None, err
        # Gradio Image expects PIL for preview; convert SVG to PNG in-memory for preview
        try:
            # Try render SVG to PNG using PIL + frombuffer via cairosvg if available
            # If cairosvg isn't installed, we fallback to returning a small raster via RDKit PNG
            try:
                import cairosvg
                png_bytes = cairosvg.svg2png(bytestring=svg.encode("utf-8"), output_width=int(width), output_height=int(height))
                img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
            except Exception:
                # fallback: rasterize via RDKit's MolToImage
                img, err2 = mol_image_from_smiles(smiles, width, height)
                if err2:
                    return None, err2
            return img, f"绘制成功({fmt})"
        except Exception as e:
            return None, f"预览 SVG 转换失败: {e}"
    else:
        img, err = mol_image_from_smiles(smiles, width, height)
        if err:
            return None, err
        return img, f"绘制成功({fmt})"

def handle_download(smiles, width, height, fmt):
    if fmt == "SVG":
        svg, err = mol_svg_from_smiles(smiles, width, height)
        if err:
            return None, err
        filename = f"mol_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}.svg"
        path = os.path.join(IMAGES_DIR, filename)
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(svg)
        except Exception as e:
            return None, f"保存 SVG 失败: {e}"
        return path, f"已保存为 {filename}(SVG),可点击下载。"
    else:
        img, err = mol_image_from_smiles(smiles, width, height)
        if err:
            return None, err
        filename = f"mol_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}.png"
        path = os.path.join(IMAGES_DIR, filename)
        try:
            img.save(path, format="PNG")
        except Exception as e:
            return None, f"保存图片失败: {e}"
        return path, f"已保存为 {filename}(PNG),可点击下载。"

def handle_archive(smiles, width, height, notes, fmt):
    mol = Chem.MolFromSmiles(smiles.strip() if smiles else "")
    if mol is None:
        return None, "无法解析 SMILES(无效格式),未归档。"

    saved_image_name = ""
    # Save chosen format preview for archive
    if fmt == "SVG":
        svg, err = mol_svg_from_smiles(smiles, width, height)
        if svg:
            saved_image_name = f"arch_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}.svg"
            img_path = os.path.join(IMAGES_DIR, saved_image_name)
            try:
                with open(img_path, "w", encoding="utf-8") as f:
                    f.write(svg)
            except Exception:
                saved_image_name = ""
    else:
        img, err = mol_image_from_smiles(smiles, width, height)
        if img:
            saved_image_name = f"arch_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}.png"
            img_path = os.path.join(IMAGES_DIR, saved_image_name)
            try:
                img.save(img_path, format="PNG")
            except Exception:
                saved_image_name = ""

    entry_id = uuid.uuid4().hex
    ts = datetime.now().isoformat()
    try:
        with open(ARCHIVE_PATH, "a", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow([entry_id, ts, smiles.strip(), saved_image_name, notes or ""])
    except Exception as e:
        return None, f"追加归档失败: {e}"

    return ARCHIVE_PATH, "SMILES 已归档并写入 CSV(可点击下载或在表格中查看)。"

def read_archive_rows():
    rows = []
    if not os.path.exists(ARCHIVE_PATH):
        return rows
    with open(ARCHIVE_PATH, "r", newline="", encoding="utf-8") as f:
        reader = csv.reader(f)
        header = next(reader, None)
        for r in reader:
            # ensure length
            if len(r) < 5:
                r += [""] * (5 - len(r))
            rows.append(r)
    return rows

def handle_get_archive_table():
    rows = read_archive_rows()
    # Return header + rows for gr.Dataframe display (we'll return rows only)
    return rows

def handle_delete_entry(entry_id):
    if not entry_id:
        return False, "未选择 ID。"
    rows = read_archive_rows()
    new_rows = []
    deleted = False
    deleted_image = ""
    for r in rows:
        if r[0] == entry_id:
            deleted = True
            deleted_image = r[3]
            continue
        new_rows.append(r)
    if not deleted:
        return False, "未找到对应条目。"
    # Write back CSV with header
    try:
        with open(ARCHIVE_PATH, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["id", "timestamp", "smiles", "saved_image", "notes"])
            writer.writerows(new_rows)
    except Exception as e:
        return False, f"更新归档文件失败: {e}"
    # Attempt to delete image file
    if deleted_image:
        p = os.path.join(IMAGES_DIR, deleted_image)
        try:
            if os.path.exists(p):
                os.remove(p)
        except Exception:
            pass
    return True, "已删除该条目(如有关联图片已尝试删除)。"

def handle_download_entry_image(entry_id):
    if not entry_id:
        return None, "未选择 ID。"
    rows = read_archive_rows()
    for r in rows:
        if r[0] == entry_id:
            img_name = r[3]
            if not img_name:
                return None, "该条目无保存的图片。"
            p = os.path.join(IMAGES_DIR, img_name)
            if os.path.exists(p):
                return p, "找到并准备下载图片。"
            else:
                return None, "关联图片文件不存在。"
    return None, "未找到对应条目。"

def handle_download_entry_row(entry_id):
    if not entry_id:
        return None, "未选择 ID。"
    rows = read_archive_rows()
    for r in rows:
        if r[0] == entry_id:
            # write single-row CSV to temp path
            fname = f"entry_{entry_id}.csv"
            p = os.path.join(IMAGES_DIR, fname)
            try:
                with open(p, "w", newline="", encoding="utf-8") as f:
                    writer = csv.writer(f)
                    writer.writerow(["id", "timestamp", "smiles", "saved_image", "notes"])
                    writer.writerow(r)
                return p, "单条记录 CSV 已准备好下载。"
            except Exception as e:
                return None, f"导出单条 CSV 失败: {e}"
    return None, "未找到对应条目。"

# Build Gradio UI
with gr.Blocks(title="SMILES 分子绘制器(RDKit + Gradio)") as demo:
    gr.Markdown("## SMILES 分子绘制器\n输入 SMILES,调整画布大小,选择导出格式(PNG/SVG),添加备注后可归档;界面可查看/下载/删除归档条目。")
    with gr.Row():
        with gr.Column(scale=2):
            smiles_input = gr.Textbox(label="SMILES", placeholder="例如:CCO 或 c1ccccc1", lines=1)
            with gr.Row():
                width_input = gr.Number(value=300, label="画布宽 (px)", precision=0)
                height_input = gr.Number(value=300, label="画布高 (px)", precision=0)
                fmt_input = gr.Radio(["PNG", "SVG"], value="PNG", label="输出格式")
            notes_input = gr.Textbox(label="备注 / 标签(可选)", placeholder="例如:synth-1, interesting")
            with gr.Row():
                draw_btn = gr.Button("Draw")
                download_btn = gr.Button("Download (save file)")
                archive_btn = gr.Button("Archive SMILES")
            status = gr.Textbox(label="状态 / 信息", interactive=False)
        with gr.Column(scale=1):
            preview = gr.Image(label="预览", type="pil")

    gr.Markdown("### 归档管理")
    with gr.Row():
        with gr.Column(scale=2):
            refresh_btn = gr.Button("刷新归档表格")
            archive_table = gr.Dataframe(headers=["id", "timestamp", "smiles", "saved_image", "notes"], interactive=False)
            with gr.Row():
                select_id = gr.Textbox(label="选择条目 ID(用于下载/删除)", placeholder="在表格中复制 ID 到此处")
                download_entry_img_btn = gr.Button("下载条目图片")
                download_entry_row_btn = gr.Button("下载条目 CSV")
                delete_entry_btn = gr.Button("删除条目")
            archive_file = gr.File(label="下载文件")
        with gr.Column(scale=1):
            archive_download_btn = gr.Button("下载完整归档 CSV(最新)")

    # Wire events
    draw_btn.click(fn=handle_draw, inputs=[smiles_input, width_input, height_input, fmt_input], outputs=[preview, status])
    download_btn.click(fn=handle_download, inputs=[smiles_input, width_input, height_input, fmt_input], outputs=[archive_file, status])
    archive_btn.click(fn=handle_archive, inputs=[smiles_input, width_input, height_input, notes_input, fmt_input], outputs=[archive_file, status])

    refresh_btn.click(fn=handle_get_archive_table, inputs=None, outputs=[archive_table])
    delete_entry_btn.click(fn=handle_delete_entry, inputs=[select_id], outputs=[status, archive_file]).then(
        fn=handle_get_archive_table, inputs=None, outputs=[archive_table]
    )
    download_entry_img_btn.click(fn=handle_download_entry_image, inputs=[select_id], outputs=[archive_file, status])
    download_entry_row_btn.click(fn=handle_download_entry_row, inputs=[select_id], outputs=[archive_file, status])
    # also allow one-click download of whole archive
    def get_archive_download():
        if os.path.exists(ARCHIVE_PATH):
            return ARCHIVE_PATH, "完整归档 CSV(最新)"
        else:
            return None, "归档文件不存在。"
    archive_download_btn.click(fn=get_archive_download, inputs=None, outputs=[archive_file, status])

if __name__ == "__main__":
    demo.launch()

::: :::align{center}

0x6. 电子木鱼

:::

简介

全文唯一纯 c++ & 唯一纯我手肝

呃呃,有一点炸。

仅适用于类 Linux 系统!

代码

请将代码放在专用目录下运行,因为这个有存档

:::info[源代码]

#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;

int main() {
    while (1) {
        cout << "电子木鱼,修改text.txt中的文字以修改内容。\n[ ]启动\n[v]vim text.sh\n[q]退出\n[c]显示信息\n";
        char ch = getchar();
        if (ch == ' ') {
            freopen("text.txt", "r", stdin);
            string s;
            getline(cin, s);
            fclose(stdin);
            cout << "请输入敲木鱼次数,谨慎输入:\n";
            int n;
            cin >> n;
            while (n --) {
                cout << s << '\n';
            }
        } else if (ch == 'v') {
            cout << "记住按[i]键入,按[<esc>:wq]退出\n";
            system("vim text.txt");
        } else if (ch == 'q') {
            return 0;
        } else if (ch == 'c') {
            cout << "编写:@FlowerAccepted (洛谷 UID 1023732), 更新时间:2025-5-8 17:20\n";
        }
        getchar();
    }
}

::: :::align{center}

0x7. 数据生成驱动

:::

简介

很实用,放在一个地方便可以驱动所有测试数据的生成。你需要标程可执行文件Python数据生成器。

使用方法

  1. 将此脚本保存为 worker.py,放在你的所有题目工作目录下
  2. 将文件结构调整为:
    .
    ——— worker.py
    ——— <题目1目录>
    —————— <程序 IO 名>.py
    —————— <程序 IO 名>(c++编译的可执行文件)
    ——— <题目2目录>
    ——— ...
  3. cd 至你的题目目录
  4. 运行 ../worker.py, 输入程序 IO 名(不带扩展名)、测试点名前缀和数据数量
  5. 数据将生成在 ./data 目录下, 文件名格式为 <测试点名前缀><编号>.in 和 <测试点名前缀><编号>.ans
  6. 每组数据会调用标准解程序,生成对应的输出文件
  7. 生成完成后会打印每组数据的文件路径
  8. 确保你的程序 IO 名对应的 Python 脚本能正确生成输入文件, 并且标准解程序能正确处理输入并生成输出,且确保更新标准解程序

    代码

    请将代码放在你所有题目工作目录的上级

github 仓库

:::info[源代码]

'''
使用方法:
1. 将此脚本保存为 worker.py,放在你的所有题目工作目录下
2. 将文件结构调整为:
.
├── worker.py
├── <题目1目录>
├──├── <程序 IO 名>.py
├──├── <程序 IO 名>(c++编译的可执行文件)
├── <题目2目录>
├── ...
3. cd 至你的题目目录
4. 运行 `../worker.py`,
   输入程序 IO 名(不带扩展名)、测试点名前缀和数据数量
5. 数据将生成在 ./data 目录下,
   文件名格式为 <测试点名前缀><编号>.in 和 <测试点名前缀><编号>.ans
6. 每组数据会调用标准解程序,生成对应的输出文件
7. 生成完成后会打印每组数据的文件路径
8. 确保你的程序 IO 名对应的 Python 脚本能正确生成输入文件,
   并且标准解程序能正确处理输入并生成输出,且确保更新标准解程序
'''
import os
import shutil
import subprocess

def main():
    s = input("请输入程序 IO 名(不带扩展名): ").strip()
    t = input("请输入测试点名前缀: ").strip()
    n = int(input("请输入数据数量: ").strip())

    data_dir = "./data"
    os.makedirs(data_dir, exist_ok=True)

    for i in range(1, n + 1):
        # 生成数据
        subprocess.run(["python", f"./{s}.py"], check=True)

        # 调用标准解
        subprocess.run([f"./{s}"], check=True)

        # 文件名
        in_src = f"./{s}.in"
        out_src = f"./{s}.out"
        in_dst = os.path.join(data_dir, f"{t}{i}.in")
        ans_dst = os.path.join(data_dir, f"{t}{i}.ans")

        # 移动并重命名
        shutil.move(in_src, in_dst)
        shutil.move(out_src, ans_dst)

        print(f"第{i}组数据生成完成: {in_dst}, {ans_dst}")

if __name__ == "__main__":
    main()

:::