当前位置: 首页 > news >正文

Umeyama 算法之源码阅读与测试

Title: Umeyama 算法之源码阅读与测试

文章目录

  • 前言
  • I. Eigen 中 Umeyama 算法源码
    • 1. Eigen/src/Geometry/Umeyama.h 源码
    • 2. 代码测试
  • II. PCL 中 Umeyama 算法源码
  • III. evo 中 Umeyama 算法源码
    • 1. evo/core/geometry.py 源码
    • 2. 代码测试
  • 总结
  • 参考文献


[相关博文介绍]

- 矩阵乘法可交换与可同时对角化的关系 —— Umeyama 算法推导的数学准备 (I)

- 旋转矩阵约束下的朗格朗日乘子 —— Umeyama 算法推导的数学准备 (II)

- 矩阵乘操作、三角化、开方特征值 —— Umeyama 算法推导的数学准备 (III)

- 奇异值分解之常用结论

- 旋转参数的最小方差估计 —— Umeyama 算法详细推导 (I)

- 相似变换参数 (旋转、平移、缩放) 的最小方差估计 —— Umeyama 算法详细推导 (II)

- Umeyama 算法之源码阅读与测试


前言

SLAM 轨迹的对齐和评估时, 多用 Umeyama 算法实现.

该算法从给定的两个欧几里得空间的关联点集中找出最小误差平方意义下的相似变换参数 (Similarity Transformation, 旋转+平移+缩放)[1].

在上一篇博文中, 我们已经详细推导了该算法的数学原理.

这里我们看一下实际应用中 Umeyama 算法的源码实现, 分别是

- Eigen[2] 中 Umeyama 算法源码

- PCL[3] 中 Umeyama 算法源码

- evo[4] 中 Umeyama 算法源码

(看现成的算法源码还是比推导公式稍微轻松一点.)


I. Eigen 中 Umeyama 算法源码

1. Eigen/src/Geometry/Umeyama.h 源码

/**
* \geometry_module \ingroup Geometry_Module
*
* \brief Returns the transformation between two point sets.
*
* The algorithm is based on:
* "Least-squares estimation of transformation parameters between two point patterns",
* Shinji Umeyama, PAMI 1991, DOI: 10.1109/34.88573
*
* It estimates parameters \f$ c, \mathbf{R}, \f$ and \f$ \mathbf{t} \f$ such that
* \f{align*}
*   \frac{1}{n} \sum_{i=1}^n \vert\vert y_i - (c\mathbf{R}x_i + \mathbf{t}) \vert\vert_2^2
* \f}
* is minimized.
*
* The algorithm is based on the analysis of the covariance matrix
* \f$ \Sigma_{\mathbf{x}\mathbf{y}} \in \mathbb{R}^{d \times d} \f$
* of the input point sets \f$ \mathbf{x} \f$ and \f$ \mathbf{y} \f$ where 
* \f$d\f$ is corresponding to the dimension (which is typically small).
* The analysis is involving the SVD having a complexity of \f$O(d^3)\f$
* though the actual computational effort lies in the covariance
* matrix computation which has an asymptotic lower bound of \f$O(dm)\f$ when 
* the input point sets have dimension \f$d \times m\f$.
*
* Currently the method is working only for floating point matrices.
*
* \todo Should the return type of umeyama() become a Transform?
*
* \param src Source points \f$ \mathbf{x} = \left( x_1, \hdots, x_n \right) \f$.
* \param dst Destination points \f$ \mathbf{y} = \left( y_1, \hdots, y_n \right) \f$.
* \param with_scaling Sets \f$ c=1 \f$ when <code>false</code> is passed.
* \return The homogeneous transformation 
* \f{align*}
*   T = \begin{bmatrix} c\mathbf{R} & \mathbf{t} \\ \mathbf{0} & 1 \end{bmatrix}
* \f}
* minimizing the residual above. This transformation is always returned as an 
* Eigen::Matrix.
*/
template <typename Derived, typename OtherDerived>
typename internal::umeyama_transform_matrix_type<Derived, OtherDerived>::type
umeyama(const MatrixBase<Derived>& src, const MatrixBase<OtherDerived>& dst, bool with_scaling = true)
{typedef typename internal::umeyama_transform_matrix_type<Derived, OtherDerived>::type TransformationMatrixType;typedef typename internal::traits<TransformationMatrixType>::Scalar Scalar;typedef typename NumTraits<Scalar>::Real RealScalar;EIGEN_STATIC_ASSERT(!NumTraits<Scalar>::IsComplex, NUMERIC_TYPE_MUST_BE_REAL)EIGEN_STATIC_ASSERT((internal::is_same<Scalar, typename internal::traits<OtherDerived>::Scalar>::value),YOU_MIXED_DIFFERENT_NUMERIC_TYPES__YOU_NEED_TO_USE_THE_CAST_METHOD_OF_MATRIXBASE_TO_CAST_NUMERIC_TYPES_EXPLICITLY)enum { Dimension = EIGEN_SIZE_MIN_PREFER_DYNAMIC(Derived::RowsAtCompileTime, OtherDerived::RowsAtCompileTime) };typedef Matrix<Scalar, Dimension, 1> VectorType;typedef Matrix<Scalar, Dimension, Dimension> MatrixType;typedef typename internal::plain_matrix_type_row_major<Derived>::type RowMajorMatrixType;const Index m = src.rows(); // dimensionconst Index n = src.cols(); // number of measurements// required for demeaning ...const RealScalar one_over_n = RealScalar(1) / static_cast<RealScalar>(n);// computation of meanconst VectorType src_mean = src.rowwise().sum() * one_over_n;const VectorType dst_mean = dst.rowwise().sum() * one_over_n;// demeaning of src and dst pointsconst RowMajorMatrixType src_demean = src.colwise() - src_mean;const RowMajorMatrixType dst_demean = dst.colwise() - dst_mean;// Eq. (36)-(37)const Scalar src_var = src_demean.rowwise().squaredNorm().sum() * one_over_n;// Eq. (38)const MatrixType sigma = one_over_n * dst_demean * src_demean.transpose();JacobiSVD<MatrixType> svd(sigma, ComputeFullU | ComputeFullV);// Initialize the resulting transformation with an identity matrix...TransformationMatrixType Rt = TransformationMatrixType::Identity(m+1,m+1);// Eq. (39)VectorType S = VectorType::Ones(m);if  ( svd.matrixU().determinant() * svd.matrixV().determinant() < 0 )S(m-1) = -1;// Eq. (40) and (43)Rt.block(0,0,m,m).noalias() = svd.matrixU() * S.asDiagonal() * svd.matrixV().transpose();if (with_scaling){// Eq. (42)const Scalar c = Scalar(1)/src_var * svd.singularValues().dot(S);// Eq. (41)Rt.col(m).head(m) = dst_mean;Rt.col(m).head(m).noalias() -= c*Rt.topLeftCorner(m,m)*src_mean;Rt.block(0,0,m,m) *= c;}else{Rt.col(m).head(m) = dst_mean;Rt.col(m).head(m).noalias() -= Rt.topLeftCorner(m,m)*src_mean;}return Rt;
}

代码实现是完全按照 Umeyama 论文中的 [定理] 展开的, 理解了这些公式的来龙去脉, 就能很好的理解上面的代码. 我们没有多余的话要说的了.


2. 代码测试

我们根据 Eigen 中提供的单元测试代码 test/umeyama.cpp, 简单改写一下进行了测试.

// This file is part of Eigen, a lightweight C++ template library
// for linear algebra.
//
// Copyright (C) 2009 Hauke Heibel <hauke.heibel@gmail.com>
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.#include <Eigen/Core>
#include <Eigen/Geometry>#include <Eigen/LU> // required for MatrixBase::determinant
#include <Eigen/SVD> // required for SVD
#include <iostream>
#include <unistd.h>using namespace Eigen;//  Constructs a random matrix from the unitary group U(size).
template <typename T>
Eigen::Matrix<T, Eigen::Dynamic, Eigen::Dynamic> randMatrixUnitary(int size)
{typedef T Scalar;typedef Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic> MatrixType;MatrixType Q;int max_tries = 40;bool is_unitary = false;while (!is_unitary && max_tries > 0){// initialize random matrixQ = MatrixType::Random(size, size);// orthogonalize columns using the Gram-Schmidt algorithmfor (int col = 0; col < size; ++col){typename MatrixType::ColXpr colVec = Q.col(col);for (int prevCol = 0; prevCol < col; ++prevCol){typename MatrixType::ColXpr prevColVec = Q.col(prevCol);colVec -= colVec.dot(prevColVec)*prevColVec;}Q.col(col) = colVec.normalized();}// this additional orthogonalization is not necessary in theory but should enhance// the numerical orthogonality of the matrixfor (int row = 0; row < size; ++row){typename MatrixType::RowXpr rowVec = Q.row(row);for (int prevRow = 0; prevRow < row; ++prevRow){typename MatrixType::RowXpr prevRowVec = Q.row(prevRow);rowVec -= rowVec.dot(prevRowVec)*prevRowVec;}Q.row(row) = rowVec.normalized();}// final checkis_unitary = Q.isUnitary();--max_tries;}if (max_tries == 0)eigen_assert(false && "randMatrixUnitary: Could not construct unitary matrix!");return Q;
}//  Constructs a random matrix from the special unitary group SU(size).
template <typename T>
Eigen::Matrix<T, Eigen::Dynamic, Eigen::Dynamic> randMatrixSpecialUnitary(int size)
{typedef T Scalar;typedef Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic> MatrixType;// initialize unitary matrixMatrixType Q = randMatrixUnitary<Scalar>(size);// tweak the first column to make the determinant be 1Q.col(0) *= numext::conj(Q.determinant());return Q;
}template <typename MatrixType>
void run_test(int dim, int num_elements)
{using std::abs;typedef typename internal::traits<MatrixType>::Scalar Scalar;typedef Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic> MatrixX;typedef Matrix<Scalar, Eigen::Dynamic, 1> VectorX;// MUST be positive because in any other case det(cR_t) may become negative for// odd dimensions!srand((unsigned)time(NULL));const Scalar c = abs(internal::random<Scalar>());MatrixX R = randMatrixSpecialUnitary<Scalar>(dim);VectorX t = Scalar(50)*VectorX::Random(dim,1);MatrixX cR_t = MatrixX::Identity(dim+1,dim+1);cR_t.block(0,0,dim,dim) = c*R;cR_t.block(0,dim,dim,1) = t;std::cout << "[c]:\n" << c << "\n" << std::endl;std::cout << "[R]:\n"<< R << "\n" << std::endl;std::cout << "[t]:\n"<< t << "\n" << std::endl;std::cout << "[Original cR_t]:\n"<< cR_t << "\n" << std::endl;MatrixX src = MatrixX::Random(dim+1, num_elements);src.row(dim) = Matrix<Scalar, 1, Dynamic>::Constant(num_elements, Scalar(1));MatrixX dst = cR_t*src;MatrixX cR_t_umeyama = umeyama(src.block(0,0,dim,num_elements), dst.block(0,0,dim,num_elements));const Scalar error = ( cR_t_umeyama*src - dst ).norm() / dst.norm();// VERIFY(error < Scalar(40)*std::numeric_limits<Scalar>::epsilon());std::cout << "[cR_t_umeyama]:\n"<< cR_t_umeyama << "\n" << std::endl;std::cout << "[error]:\n"<< error << "\n" << std::endl;}int main(int argc, char** argv)
{std::cout << "\n----- First Experiment -----" << std::endl; run_test<MatrixXf>(3, 20);sleep(1);std::cout << "\n----- Second Experiment -----" << std::endl; run_test<MatrixXf>(4, 10);return 0;
}

这里我们进行了两组测试, 一组是 3 维空间中的 20 个随机数据点, 另一组是 4 维空间中的 10 个随机数据点.

将随机数据点保存为原始点云, 通过随机生成的相似变换 (旋转、平移、缩放) 从原始点云变换到目标点云.

利用 Umeyama 算法, 由原始点云和目标点云数据计算出该相似变换矩阵.

测试结果如下, 比较 [Original cR_t] 和 [cR_t_umeyama] 两个矩阵, 可以看出算法能够成功计算出相似变换参数.

----- First Experiment -----
[c]:
0.737838[R]:0.27562 -0.872498  0.403461
-0.780523 -0.448117 -0.4358620.561086 -0.194778 -0.804514[t]:4.90685
-11.29446.41211[Original cR_t]:0.203363 -0.643762  0.297689   4.90685
-0.575899 -0.330638 -0.321596  -11.29440.413991 -0.143715 -0.593601   6.412110         0         0         1[cR_t_umeyama]:0.203363 -0.643763  0.297689   4.90685
-0.575899 -0.330638 -0.321596  -11.29440.413991 -0.143715 -0.593602   6.412110         0         0         1[error]:
7.80264e-08----- Second Experiment -----
[c]:
0.0245536[R]:
-0.449366 -0.347432 -0.438747  0.696321
-0.449418  0.583524 -0.572782 -0.359784
0.0110775 -0.718994  -0.37278 -0.5864820.771991  0.147782 -0.583487  0.204285[t]:10.0548.1491
-14.796228.6888[Original cR_t]:-0.0110336  -0.0085307  -0.0107728   0.0170972       10.05-0.0110348   0.0143276  -0.0140639 -0.00883398     48.1491
0.000271992  -0.0176539 -0.00915309  -0.0144002    -14.79620.0189552  0.00362858  -0.0143267  0.00501594     28.68880           0           0           0           1[cR_t_umeyama]:-0.0110338 -0.00853069  -0.0107728   0.0170973       10.05-0.0110348   0.0143278   -0.014064 -0.00883401     48.1491
0.000271604   -0.017654 -0.00915301  -0.0144004    -14.79620.0189553  0.00362824   -0.014327  0.00501585     28.68880           0           0           0           1[error]:
7.49672e-08

II. PCL 中 Umeyama 算法源码

registration/include/pcl/registration/impl/transformation_estimation_svd.hpp 源码如下:

template <typename PointSource, typename PointTarget, typename Scalar>
inline void
TransformationEstimationSVD<PointSource, PointTarget, Scalar>::estimateRigidTransformation(ConstCloudIterator<PointSource>& source_it,ConstCloudIterator<PointTarget>& target_it,Matrix4& transformation_matrix) const
{// Convert to Eigen formatconst int npts = static_cast<int>(source_it.size());if (use_umeyama_) {Eigen::Matrix<Scalar, 3, Eigen::Dynamic> cloud_src(3, npts);Eigen::Matrix<Scalar, 3, Eigen::Dynamic> cloud_tgt(3, npts);for (int i = 0; i < npts; ++i) {cloud_src(0, i) = source_it->x;cloud_src(1, i) = source_it->y;cloud_src(2, i) = source_it->z;++source_it;cloud_tgt(0, i) = target_it->x;cloud_tgt(1, i) = target_it->y;cloud_tgt(2, i) = target_it->z;++target_it;}// Call Umeyama directly from Eigen (PCL patched version until Eigen is released)transformation_matrix = pcl::umeyama(cloud_src, cloud_tgt, false);}else {source_it.reset();target_it.reset();// <cloud_src,cloud_src> is the source datasettransformation_matrix.setIdentity();Eigen::Matrix<Scalar, 4, 1> centroid_src, centroid_tgt;// Estimate the centroids of source, targetcompute3DCentroid(source_it, centroid_src);compute3DCentroid(target_it, centroid_tgt);source_it.reset();target_it.reset();// Subtract the centroids from source, targetEigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic> cloud_src_demean,cloud_tgt_demean;demeanPointCloud(source_it, centroid_src, cloud_src_demean);demeanPointCloud(target_it, centroid_tgt, cloud_tgt_demean);getTransformationFromCorrelation(cloud_src_demean,centroid_src,cloud_tgt_demean,centroid_tgt,transformation_matrix);}
}template <typename PointSource, typename PointTarget, typename Scalar>
void
TransformationEstimationSVD<PointSource, PointTarget, Scalar>::getTransformationFromCorrelation(const Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic>& cloud_src_demean,const Eigen::Matrix<Scalar, 4, 1>& centroid_src,const Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic>& cloud_tgt_demean,const Eigen::Matrix<Scalar, 4, 1>& centroid_tgt,Matrix4& transformation_matrix) const
{transformation_matrix.setIdentity();// Assemble the correlation matrix H = source * target'Eigen::Matrix<Scalar, 3, 3> H =(cloud_src_demean * cloud_tgt_demean.transpose()).topLeftCorner(3, 3);// Compute the Singular Value DecompositionEigen::JacobiSVD<Eigen::Matrix<Scalar, 3, 3>> svd(H, Eigen::ComputeFullU | Eigen::ComputeFullV);Eigen::Matrix<Scalar, 3, 3> u = svd.matrixU();Eigen::Matrix<Scalar, 3, 3> v = svd.matrixV();// Compute R = V * U'if (u.determinant() * v.determinant() < 0) {for (int x = 0; x < 3; ++x)v(x, 2) *= -1;}Eigen::Matrix<Scalar, 3, 3> R = v * u.transpose();// Return the correct transformationtransformation_matrix.topLeftCorner(3, 3) = R;const Eigen::Matrix<Scalar, 3, 1> Rc(R * centroid_src.head(3));transformation_matrix.block(0, 3, 3, 1) = centroid_tgt.head(3) - Rc;
}} // namespace registration
} // namespace pcl

PCL 中做的是 3 维欧式空间中刚体变换, 不涉及高维空间, 且只计算了旋转参数和平移参数, 而不涉及缩放.

实现方法分为一种直接调用 Eigen 中的 Umeyama 算法实现, 另一种在 PCL 内自己实现. PCL 自己实现的部分和 Umeyama 算法类似, 确切地说是 Umeyama 算法的上一代算法. 可参考文献 [5], 我们此处不展开.


III. evo 中 Umeyama 算法源码

1. evo/core/geometry.py 源码

def umeyama_alignment(x: np.ndarray, y: np.ndarray,with_scale: bool = False) -> UmeyamaResult:"""Computes the least squares solution parameters of an Sim(m) matrixthat minimizes the distance between a set of registered points.Umeyama, Shinji: Least-squares estimation of transformation parametersbetween two point patterns. IEEE PAMI, 1991:param x: mxn matrix of points, m = dimension, n = nr. of data points:param y: mxn matrix of points, m = dimension, n = nr. of data points:param with_scale: set to True to align also the scale (default: 1.0 scale):return: r, t, c - rotation matrix, translation vector and scale factor"""if x.shape != y.shape:raise GeometryException("data matrices must have the same shape")# m = dimension, n = nr. of data pointsm, n = x.shape# means, eq. 34 and 35mean_x = x.mean(axis=1)mean_y = y.mean(axis=1)# variance, eq. 36# "transpose" for column subtractionsigma_x = 1.0 / n * (np.linalg.norm(x - mean_x[:, np.newaxis])**2)# covariance matrix, eq. 38outer_sum = np.zeros((m, m))for i in range(n):outer_sum += np.outer((y[:, i] - mean_y), (x[:, i] - mean_x))cov_xy = np.multiply(1.0 / n, outer_sum)# SVD (text betw. eq. 38 and 39)u, d, v = np.linalg.svd(cov_xy)if np.count_nonzero(d > np.finfo(d.dtype).eps) < m - 1:raise GeometryException("Degenerate covariance rank, ""Umeyama alignment is not possible")# S matrix, eq. 43s = np.eye(m)if np.linalg.det(u) * np.linalg.det(v) < 0.0:# Ensure a RHS coordinate system (Kabsch algorithm).s[m - 1, m - 1] = -1# rotation, eq. 40r = u.dot(s).dot(v)# scale & translation, eq. 42 and 41c = 1 / sigma_x * np.trace(np.diag(d).dot(s)) if with_scale else 1.0t = mean_y - np.multiply(c, r.dot(mean_x))return r, t, c

很直接地由 Umeyama 论文中的 [定理] 翻译成了 Python 代码进行实现, 不做过多解释了.


2. 代码测试

根据 doc/alignment_demo.py 示例程序, 简单修改并测试.

import copy
import logging
import sysimport evo.core.lie_algebra as lie
from evo.core import trajectory
from evo.tools import plot, file_interface, logimport numpy as np
import matplotlib.pyplot as pltlogger = logging.getLogger("evo")
log.configure_logging(verbose=True)traj_ref = file_interface.read_kitti_poses_file("../test/data/KITTI_00_gt.txt")
traj_est = file_interface.read_kitti_poses_file("../test/data/KITTI_00_ORB.txt")# add artificial Sim(3) transformation
traj_est.transform(lie.se3(lie.so3_exp([-0.7,2.3,1.2]), np.array([-215.7, -114.1, -198.3])))
# traj_est.transform(lie.se3(np.eye(3), np.array([0, 0, 0])))
traj_est.scale(0.7)logger.info("\nUmeyama alignment without scaling")
traj_est_aligned = copy.deepcopy(traj_est)
traj_est_aligned.align(traj_ref)logger.info("\nUmeyama alignment with scaling")
traj_est_aligned_scaled = copy.deepcopy(traj_est)
traj_est_aligned_scaled.align(traj_ref, correct_scale=True)logger.info("\nUmeyama alignment with scaling only")
traj_est_aligned_only_scaled = copy.deepcopy(traj_est)
traj_est_aligned_only_scaled.align(traj_ref, correct_only_scale=True)fig = plt.figure(figsize=(8, 8))
plot_mode = plot.PlotMode.xyzax = plot.prepare_axis(fig, plot_mode, subplot_arg=221)
plot.traj(ax, plot_mode, traj_ref, '--', 'red')
plot.traj(ax, plot_mode, traj_est, '-', 'blue')
fig.axes.append(ax)
plt.title('not aligned')ax = plot.prepare_axis(fig, plot_mode, subplot_arg=222)
plot.traj(ax, plot_mode, traj_ref, '--', 'red')
plot.traj(ax, plot_mode, traj_est_aligned, '-', 'blue')
fig.axes.append(ax)
plt.title('$\mathrm{SE}(3)$ alignment without scaling')ax = plot.prepare_axis(fig, plot_mode, subplot_arg=223)
plot.traj(ax, plot_mode, traj_ref, '--', 'red')
plot.traj(ax, plot_mode, traj_est_aligned_scaled, '-', 'blue')
fig.axes.append(ax)
plt.title('$\mathrm{Sim}(3)$ alignment')ax = plot.prepare_axis(fig, plot_mode, subplot_arg=224)
plot.traj(ax, plot_mode, traj_ref, '--', 'red')
plot.traj(ax, plot_mode, traj_est_aligned_only_scaled, '-', 'blue')
fig.axes.append(ax)
plt.title('only scaled')fig.tight_layout()
plt.show()

结果如下图所示, 左上角是原始的两组数据 (未对齐), 右上角仅计算了平移和旋转 (没有缩放) 后的 “对齐” 结果, 右下角仅计算了缩放 (没有旋转和平移) 后的 “对齐” 结果, 左下角是计算了相似变换 (旋转、平移、缩放) 后的对齐结果. 可以看出相似变换后的对齐效果比较好, 体现 Umeyama 算法的较好的特性.

evo_result

总结

本篇博文中

- 查阅了 Eigen 中 Umeyama 算法源码并简单测试

- 查阅了 PCL 中 Umeyama 算法源码 (基于 Eigen)

- 查阅了 evo 中 Umeyama 算法源码并简单测试

到此, 我们完成了对 Umeyama 算法的数学推导和源码阅读, 可以暂时画个休止符了.

(如有问题, 请指出)


参考文献

[1] S. Umeyama, “Least-squares estimation of transformation parameters between two point patterns,” in IEEE Transactions on Pattern Analysis and Machine Intelligence, vol. 13, no. 4, pp. 376-380, April 1991

[2] Eigen, https://eigen.tuxfamily.org/index.php?title=Main_Page

[3] The Point Cloud Library (PCL), https://pointclouds.org/

[4] Grupp Michael, “evo: Python package for the evaluation of odometry and SLAM”, https://github.com/MichaelGrupp/evo

[5] Kaxlamangla S. Arun and Thomas S. Huang and Steven D. Blostein, “Least-Squares Fitting of Two 3-D Point Sets”, IEEE Transactions on Pattern Analysis and Machine Intelligence, 1987, PAMI-9, pages 698-700

相关文章:

  • 如何在PPT中去除编辑密码?
  • 基于Spring、SpringMVC、MyBatis的漫画网站
  • VS Code画流程图:draw.io插件
  • uniapp大概是怎么个开发法(前端)
  • Telnet 测试 UDP 端口?
  • hash算法
  • 【Android】Dagger2 通过 AndroidInjector 简化模版代码
  • HarmonyOS开发(二):TypeScript入门
  • 移动端APP版本治理
  • C复习-预处理器:define+条件编译+文件包含
  • Redis05-集群方案
  • 新版软考高项试题分析精选(三)
  • SoftwareTest6 - 用 Selenium 怎么点点点
  • Alter database open fails with ORA-00600 kcratr_nab_less_than_odr
  • CSS BFC是什么,应用实例
  • [译]CSS 居中(Center)方法大合集
  • 〔开发系列〕一次关于小程序开发的深度总结
  • Angular js 常用指令ng-if、ng-class、ng-option、ng-value、ng-click是如何使用的?
  • docker python 配置
  • ES6 学习笔记(一)let,const和解构赋值
  • JavaWeb(学习笔记二)
  • java小心机(3)| 浅析finalize()
  • Java知识点总结(JDBC-连接步骤及CRUD)
  • maya建模与骨骼动画快速实现人工鱼
  • React-生命周期杂记
  • SpingCloudBus整合RabbitMQ
  • Wamp集成环境 添加PHP的新版本
  • 分享一份非常强势的Android面试题
  • 技术:超级实用的电脑小技巧
  • 利用DataURL技术在网页上显示图片
  • 排序算法之--选择排序
  • 容器服务kubernetes弹性伸缩高级用法
  • 深入浅出Node.js
  • 使用Gradle第一次构建Java程序
  • 手机app有了短信验证码还有没必要有图片验证码?
  • 06-01 点餐小程序前台界面搭建
  • 哈罗单车融资几十亿元,蚂蚁金服与春华资本加持 ...
  • ​力扣解法汇总946-验证栈序列
  • #!/usr/bin/python与#!/usr/bin/env python的区别
  • #laravel 通过手动安装依赖PHPExcel#
  • #pragma once
  • #Z2294. 打印树的直径
  • (读书笔记)Javascript高级程序设计---ECMAScript基础
  • (免费领源码)Java#ssm#MySQL 创意商城03663-计算机毕业设计项目选题推荐
  • (三)Hyperledger Fabric 1.1安装部署-chaincode测试
  • (中等) HDU 4370 0 or 1,建模+Dijkstra。
  • (转)Linq学习笔记
  • .NET MVC第三章、三种传值方式
  • .NET 服务 ServiceController
  • .NET 设计模式初探
  • /etc/fstab 只读无法修改的解决办法
  • @LoadBalanced 和 @RefreshScope 同时使用,负载均衡失效分析
  • [BZOJ4016][FJOI2014]最短路径树问题
  • [C++] 如何使用Visual Studio 2022 + QT6创建桌面应用
  • [CISCN2019 华东南赛区]Web4