新增 汽车360度全景展示移动端Web应用

- 实现触摸滑动旋转查看汽车360度全景
- 添加惯性滑动效果和自动旋转展示功能
- 实现全屏沉浸式体验和精美Loading动画
- 添加Python视频帧提取脚本和浏览器端提取工具
- 包含本地HTTP服务器和完整项目文档
This commit is contained in:
2026-03-29 00:54:34 +08:00
commit 26defd9d69
345 changed files with 1513 additions and 0 deletions

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# 汽车360°全景展示应用
一个移动端 Web 应用,用于汽车的 360 度全景交互展示。
## 功能特性
- **触摸滑动旋转** - 左右滑动查看汽车 360 度全景
- **惯性滑动效果** - 滑动结束后保持惯性继续旋转
- **自动旋转展示** - 空闲时自动缓慢旋转展示
- **全屏沉浸体验** - 全屏显示,沉浸式查看
- **精美加载动画** - 汽车图标描边动画 + 进度条
- **移动端优化** - 专为移动端触摸交互设计
## 文件结构
```
3D-pano/
├── index.html # 主页面
├── app.js # 应用逻辑
├── styles.css # 样式文件
├── server.js # 本地 HTTP 服务器
├── extract_frames.py # Python 视频帧提取脚本
├── extract.html # 浏览器端帧提取工具
├── video.mp4 # 源视频文件
├── images/ # 序列帧图片目录
│ ├── car_001.jpg
│ ├── car_002.jpg
│ └── ...
└── package.json # 项目配置
```
## 快速开始
### 1. 提取序列帧
**方法一Python 脚本(推荐)**
```bash
python extract_frames.py
```
需要安装 OpenCV
```bash
pip install opencv-python
```
**方法二:浏览器工具**
1. 启动本地服务器见步骤2
2. 访问 `http://localhost:9000/extract.html`
3. 点击「开始提取」
4. 点击「应用到展示页」
### 2. 启动本地服务器
```bash
node server.js
```
服务器将在 `http://localhost:9000` 启动。
### 3. 访问应用
在浏览器中打开 `http://localhost:9000/index.html`
## 配置说明
`app.js` 中可调整以下参数:
```javascript
this.config = {
totalFrames: 336, // 总帧数
autoRotateSpeed: 80, // 自动旋转速度ms/帧)
autoRotateDelay: 3000, // 停止操作后自动旋转延迟ms
inertiaFriction: 0.95, // 惯性摩擦系数
imagePath: './images/', // 图片路径
imagePrefix: 'car_', // 图片前缀
imageExt: '.jpg' // 图片扩展名
};
```
## 图片命名规范
序列帧图片命名格式:`car_XXX.jpg`
- 前缀:`car_`
- 编号:三位数字,从 001 开始
- 扩展名:`.jpg`
示例:`car_001.jpg`, `car_002.jpg`, ..., `car_336.jpg`
## 技术栈
- **前端**:原生 HTML5 + CSS3 + JavaScript
- **Canvas**2D 渲染引擎
- **触摸交互**Touch Events API
- **视频处理**Python OpenCV
## 浏览器兼容性
- Chrome for Android ✅
- Safari iOS ✅
- Firefox Mobile ✅
- 微信内置浏览器 ✅
## 性能优化建议
1. 图片压缩:建议每张图片 50-100KB
2. 帧数选择36-72 帧适合网络传输180-360 帧适合本地展示
3. 使用 WebP 格式可进一步减小体积
## 开发者
如需修改或扩展功能,请参考源码注释。
---
**注意**:本应用需要通过 HTTP 服务器访问,直接打开 HTML 文件可能因浏览器安全限制导致图片加载失败。

515
app.js Normal file
View File

@@ -0,0 +1,515 @@
/**
* 汽车360°全景展示应用
* 支持触摸滑动、自动旋转、惯性效果
*/
class Car360Viewer {
constructor() {
// 配置
this.config = {
totalFrames: 336, // 总帧数
framesPerSwipe: 1, // 每像素滑动切换的帧数
inertiaFriction: 0.95, // 惯性摩擦系数
minVelocity: 0.1, // 最小速度阈值
autoRotateSpeed: 80, // 自动旋转速度ms/帧)
autoRotateDelay: 3000, // 停止操作后自动旋转延迟ms
imagePath: './images/', // 图片路径
imagePrefix: 'car_', // 图片前缀
imageExt: '.jpg' // 图片扩展名
};
// 状态
this.state = {
currentFrame: 0,
isDragging: false,
startX: 0,
lastX: 0,
velocity: 0,
images: [],
loaded: false,
animationId: null,
autoRotateTimer: null,
autoRotateInterval: null
};
// DOM元素
this.elements = {
canvas: document.getElementById('carCanvas'),
loadingScreen: document.getElementById('loadingScreen'),
loadingProgress: document.getElementById('loadingProgress'),
loadingText: document.getElementById('loadingText')
};
this.ctx = this.elements.canvas.getContext('2d');
this.init();
}
init() {
this.setupCanvas();
this.bindEvents();
this.loadImages();
}
setupCanvas() {
const rect = this.elements.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.elements.canvas.width = rect.width * dpr;
this.elements.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
// 监听窗口大小变化
window.addEventListener('resize', () => {
const newRect = this.elements.canvas.getBoundingClientRect();
this.elements.canvas.width = newRect.width * dpr;
this.elements.canvas.height = newRect.height * dpr;
this.ctx.scale(dpr, dpr);
this.renderFrame(this.state.currentFrame);
});
}
bindEvents() {
const viewer = this.elements.canvas;
// 触摸事件
viewer.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
viewer.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
viewer.addEventListener('touchend', this.handleEnd.bind(this), { passive: false });
}
handleStart(e) {
e.preventDefault();
this.stopInertia();
this.stopAutoRotate();
const touch = e.touches[0];
this.state.isDragging = true;
this.state.startX = touch.clientX;
this.state.lastX = touch.clientX;
this.state.velocity = 0;
}
handleMove(e) {
if (!this.state.isDragging) return;
e.preventDefault();
const touch = e.touches[0];
const deltaX = touch.clientX - this.state.lastX;
// 计算速度
this.state.velocity = deltaX;
// 根据滑动距离更新帧
const frameDelta = Math.round(deltaX / 5);
if (frameDelta !== 0) {
this.state.lastX = touch.clientX;
this.updateFrame(frameDelta);
}
}
handleEnd(e) {
if (!this.state.isDragging) return;
this.state.isDragging = false;
// 启动惯性动画
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
this.startInertia();
} else {
// 没有惯性,延迟启动自动旋转
this.scheduleAutoRotate();
}
}
startInertia() {
const animate = () => {
this.state.velocity *= this.config.inertiaFriction;
if (Math.abs(this.state.velocity) > this.config.minVelocity) {
const frameDelta = Math.round(this.state.velocity / 5);
if (frameDelta !== 0) {
this.updateFrame(frameDelta);
}
this.state.animationId = requestAnimationFrame(animate);
} else {
// 惯性结束,启动自动旋转
this.scheduleAutoRotate();
}
};
this.state.animationId = requestAnimationFrame(animate);
}
stopInertia() {
if (this.state.animationId) {
cancelAnimationFrame(this.state.animationId);
this.state.animationId = null;
}
this.state.velocity = 0;
}
updateFrame(delta) {
let newFrame = this.state.currentFrame + delta;
// 循环处理
while (newFrame < 0) newFrame += this.config.totalFrames;
newFrame = newFrame % this.config.totalFrames;
this.state.currentFrame = newFrame;
this.renderFrame(newFrame);
}
async loadImages() {
// 首先尝试加载文件系统图片
try {
const images = [];
let loadedCount = 0;
this.updateLoadingProgress(0, '正在加载图片资源...');
for (let i = 0; i < this.config.totalFrames; i++) {
const img = new Image();
const frameNum = String(i + 1).padStart(3, '0');
img.src = `${this.config.imagePath}${this.config.imagePrefix}${frameNum}${this.config.imageExt}`;
await new Promise((resolve, reject) => {
img.onload = () => {
loadedCount++;
const progress = Math.round((loadedCount / this.config.totalFrames) * 100);
this.updateLoadingProgress(progress, `加载中 ${loadedCount}/${this.config.totalFrames}`);
resolve(img);
};
img.onerror = () => reject(new Error(`Failed to load frame ${i}`));
});
images.push(img);
}
this.updateLoadingProgress(100, '加载完成');
this.state.images = images;
this.state.loaded = true;
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
console.log('Loaded', images.length, 'frames from files');
return;
} catch (error) {
console.log('File load failed:', error.message);
}
// 尝试从 IndexedDB 加载
this.updateLoadingProgress(0, '正在从缓存加载...');
const indexedDBFrames = await this.loadFromIndexedDB();
if (indexedDBFrames && indexedDBFrames.length > 0) {
this.state.images = indexedDBFrames;
this.state.loaded = true;
this.config.totalFrames = indexedDBFrames.length;
this.updateLoadingProgress(100, '加载完成');
setTimeout(() => {
this.hideLoading();
this.renderFrame(0);
}, 500);
return;
}
// 演示模式
console.log('Using demo mode');
this.updateLoadingProgress(100, '进入演示模式');
setTimeout(() => {
this.hideLoading();
this.startDemoMode();
}, 500);
}
updateLoadingProgress(percent, text) {
if (this.elements.loadingProgress) {
this.elements.loadingProgress.style.width = `${percent}%`;
}
if (this.elements.loadingText) {
this.elements.loadingText.textContent = text;
}
}
hideLoading() {
if (this.elements.loadingScreen) {
this.elements.loadingScreen.classList.add('hidden');
}
// 加载完成后启动自动旋转
this.startAutoRotate();
}
scheduleAutoRotate() {
// 清除之前的定时器
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
}
// 延迟启动自动旋转
this.state.autoRotateTimer = setTimeout(() => {
this.startAutoRotate();
}, this.config.autoRotateDelay);
}
startAutoRotate() {
// 清除之前的自动旋转
this.stopAutoRotate();
// 启动新的自动旋转
this.state.autoRotateInterval = setInterval(() => {
this.updateFrame(1);
}, this.config.autoRotateSpeed);
}
stopAutoRotate() {
if (this.state.autoRotateTimer) {
clearTimeout(this.state.autoRotateTimer);
this.state.autoRotateTimer = null;
}
if (this.state.autoRotateInterval) {
clearInterval(this.state.autoRotateInterval);
this.state.autoRotateInterval = null;
}
}
loadFromIndexedDB() {
return new Promise((resolve) => {
const request = indexedDB.open('Car360DB', 1);
request.onerror = () => {
console.log('IndexedDB error');
resolve(null);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('frames')) {
db.createObjectStore('frames', { keyPath: 'id' });
}
};
request.onsuccess = async () => {
const db = request.result;
try {
if (!db.objectStoreNames.contains('frames')) {
db.close();
resolve(null);
return;
}
const tx = db.transaction('frames', 'readonly');
const store = tx.objectStore('frames');
const getRequest = store.get('frames');
getRequest.onsuccess = async () => {
if (getRequest.result && getRequest.result.data && getRequest.result.data.length > 0) {
console.log('Loading', getRequest.result.data.length, 'frames from IndexedDB');
const images = getRequest.result.data.map(dataUrl => {
const img = new Image();
img.src = dataUrl;
return img;
});
await Promise.all(images.map(img =>
new Promise(res => {
img.onload = () => res();
img.onerror = () => res();
})
));
db.close();
resolve(images);
} else {
console.log('No frames data in IndexedDB');
db.close();
resolve(null);
}
};
getRequest.onerror = () => {
console.log('Failed to get frames from IndexedDB');
db.close();
resolve(null);
};
} catch (e) {
console.log('IndexedDB exception:', e);
db.close();
resolve(null);
}
};
});
}
startDemoMode() {
// 演示模式:生成占位图形
this.state.loaded = true;
this.renderFrame(0);
}
renderFrame(frameIndex) {
const canvas = this.elements.canvas;
const ctx = this.ctx;
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
const img = this.state.images[frameIndex];
if (img && img.complete && img.naturalWidth > 0) {
// 绘制真实图片
const scale = Math.max(width / img.naturalWidth, height / img.naturalHeight);
const x = (width - img.naturalWidth * scale) / 2;
const y = (height - img.naturalHeight * scale) / 2;
ctx.drawImage(img, x, y, img.naturalWidth * scale, img.naturalHeight * scale);
} else {
// 演示模式:绘制占位图形
this.drawDemoFrame(ctx, width, height, frameIndex);
}
}
drawDemoFrame(ctx, width, height, frameIndex) {
const angle = (frameIndex / this.config.totalFrames) * Math.PI * 2;
// 背景渐变
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(1, '#16213e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// 网格效果
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
ctx.lineWidth = 1;
const gridSize = 30;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// 绘制示意汽车简化的3D效果
const centerX = width / 2;
const centerY = height / 2;
// 车身主体
ctx.save();
ctx.translate(centerX, centerY);
// 阴影
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.ellipse(0, height * 0.25, width * 0.35, height * 0.08, 0, 0, Math.PI * 2);
ctx.fill();
// 车身渐变
const bodyGradient = ctx.createLinearGradient(0, -height * 0.3, 0, height * 0.2);
bodyGradient.addColorStop(0, '#4a4a5a');
bodyGradient.addColorStop(0.5, '#2a2a3a');
bodyGradient.addColorStop(1, '#1a1a2a');
// 车身形状(根据角度变化)
const perspective = Math.cos(angle);
const bodyWidth = width * 0.35 * Math.abs(perspective) + width * 0.15;
const bodyHeight = height * 0.18;
ctx.fillStyle = bodyGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth, -bodyHeight, bodyWidth * 2, bodyHeight * 2, 10);
ctx.fill();
// 车顶
const roofGradient = ctx.createLinearGradient(0, -height * 0.35, 0, -height * 0.15);
roofGradient.addColorStop(0, '#5a5a6a');
roofGradient.addColorStop(1, '#3a3a4a');
ctx.fillStyle = roofGradient;
ctx.beginPath();
ctx.roundRect(-bodyWidth * 0.7, -height * 0.35, bodyWidth * 1.4, height * 0.2, [15, 15, 5, 5]);
ctx.fill();
// 车窗
ctx.fillStyle = 'rgba(0, 212, 255, 0.3)';
ctx.beginPath();
const windowWidth = bodyWidth * 0.5;
ctx.roundRect(-windowWidth, -height * 0.32, windowWidth * 2, height * 0.12, 5);
ctx.fill();
// 车灯光效
const lightIntensity = 0.5 + Math.sin(angle * 2) * 0.3;
// 左车灯
const leftLightGradient = ctx.createRadialGradient(
-bodyWidth * 0.8, 0, 0,
-bodyWidth * 0.8, 0, 30
);
leftLightGradient.addColorStop(0, `rgba(255, 220, 100, ${lightIntensity})`);
leftLightGradient.addColorStop(1, 'rgba(255, 220, 100, 0)');
ctx.fillStyle = leftLightGradient;
ctx.beginPath();
ctx.arc(-bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 右车灯
const rightLightGradient = ctx.createRadialGradient(
bodyWidth * 0.8, 0, 0,
bodyWidth * 0.8, 0, 30
);
rightLightGradient.addColorStop(0, `rgba(255, 100, 100, ${lightIntensity})`);
rightLightGradient.addColorStop(1, 'rgba(255, 100, 100, 0)');
ctx.fillStyle = rightLightGradient;
ctx.beginPath();
ctx.arc(bodyWidth * 0.8, 0, 30, 0, Math.PI * 2);
ctx.fill();
// 车轮
ctx.fillStyle = '#1a1a2a';
ctx.strokeStyle = '#3a3a4a';
ctx.lineWidth = 3;
// 左轮
ctx.beginPath();
ctx.ellipse(-bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 右轮
ctx.beginPath();
ctx.ellipse(bodyWidth * 0.6, bodyHeight, 25, 20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
// 帧号显示
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText(`${frameIndex + 1}/${this.config.totalFrames}`, centerX, height - 20);
// 演示模式提示
ctx.fillStyle = 'rgba(0, 212, 255, 0.6)';
ctx.font = '12px sans-serif';
ctx.fillText('演示模式 - 请添加实际序列帧图片到 images 文件夹', centerX, 30);
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.carViewer = new Car360Viewer();
});

456
extract.html Normal file
View File

@@ -0,0 +1,456 @@
<!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;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
padding: 20px;
color: #fff;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 30px;
background: linear-gradient(135deg, #00d4ff, #ff6b35);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.video-container {
position: relative;
width: 100%;
background: #000;
border-radius: 12px;
overflow: hidden;
margin-bottom: 15px;
}
video {
width: 100%;
display: block;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.control-group {
flex: 1;
min-width: 150px;
}
label {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 5px;
}
input[type="number"] {
width: 100%;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 14px;
}
input[type="number"]:focus {
outline: none;
border-color: #00d4ff;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.progress-container {
margin: 20px 0;
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #ff6b35);
width: 0%;
transition: width 0.3s;
}
.progress-text {
text-align: center;
margin-top: 10px;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
max-height: 400px;
overflow-y: auto;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.preview-item {
position: relative;
aspect-ratio: 16/9;
background: #000;
border-radius: 4px;
overflow: hidden;
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-item .frame-num {
position: absolute;
bottom: 2px;
right: 2px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
}
.actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 20px;
}
.status {
text-align: center;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.status.success {
background: rgba(0, 200, 100, 0.2);
color: #00c864;
}
.status.error {
background: rgba(255, 100, 100, 0.2);
color: #ff6464;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>视频序列帧提取工具</h1>
<div class="card">
<div class="video-container">
<video id="video" src="video.mp4" controls></video>
</div>
<div class="controls">
<div class="control-group">
<label>提取帧数</label>
<input type="number" id="frameCount" value="36" min="1" max="360">
</div>
<div class="control-group">
<label>输出宽度 (px)</label>
<input type="number" id="outputWidth" value="800" min="100" max="2000">
</div>
<div class="control-group">
<label>输出质量 (1-100)</label>
<input type="number" id="quality" value="90" min="1" max="100">
</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="extractBtn">开始提取</button>
</div>
</div>
<div class="progress-container hidden" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">准备中...</div>
</div>
<div id="statusBox" class="status hidden"></div>
<div class="card hidden" id="previewCard">
<h3 style="margin-bottom: 15px;">提取预览</h3>
<div class="preview-grid" id="previewGrid"></div>
<div class="actions">
<button class="btn btn-secondary" id="downloadAllBtn">打包下载 (ZIP)</button>
<button class="btn btn-primary" id="useFramesBtn">应用到展示页</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
class FrameExtractor {
constructor() {
this.video = document.getElementById('video');
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.frames = [];
this.bindEvents();
}
bindEvents() {
document.getElementById('extractBtn').addEventListener('click', () => this.extract());
document.getElementById('downloadAllBtn').addEventListener('click', () => this.downloadAll());
document.getElementById('useFramesBtn').addEventListener('click', () => this.useFrames());
}
async extract() {
const frameCount = parseInt(document.getElementById('frameCount').value) || 36;
const outputWidth = parseInt(document.getElementById('outputWidth').value) || 800;
const quality = (parseInt(document.getElementById('quality').value) || 90) / 100;
this.frames = [];
this.video.currentTime = 0;
const duration = this.video.duration;
const interval = duration / frameCount;
// 设置 canvas 尺寸
const aspectRatio = this.video.videoHeight / this.video.videoWidth;
this.canvas.width = outputWidth;
this.canvas.height = Math.round(outputWidth * aspectRatio);
// 显示进度
document.getElementById('progressContainer').classList.remove('hidden');
document.getElementById('previewCard').classList.add('hidden');
this.updateProgress(0, '准备提取...');
try {
for (let i = 0; i < frameCount; i++) {
const time = i * interval;
await this.seekTo(time);
const frame = await this.captureFrame(i, quality);
this.frames.push(frame);
this.updateProgress(
((i + 1) / frameCount) * 100,
`提取中... ${i + 1}/${frameCount}`
);
}
this.showStatus('success', `成功提取 ${frameCount} 帧!`);
this.showPreview();
} catch (error) {
this.showStatus('error', `提取失败: ${error.message}`);
}
}
seekTo(time) {
return new Promise((resolve) => {
this.video.currentTime = time;
this.video.onseeked = () => resolve();
});
}
captureFrame(index, quality) {
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);
const dataUrl = this.canvas.toDataURL('image/jpeg', quality);
return { index, dataUrl };
}
updateProgress(percent, text) {
document.getElementById('progressFill').style.width = `${percent}%`;
document.getElementById('progressText').textContent = text;
}
showStatus(type, message) {
const box = document.getElementById('statusBox');
box.className = `status ${type}`;
box.textContent = message;
box.classList.remove('hidden');
}
showPreview() {
const grid = document.getElementById('previewGrid');
grid.innerHTML = '';
this.frames.forEach((frame, i) => {
const item = document.createElement('div');
item.className = 'preview-item';
item.innerHTML = `
<img src="${frame.dataUrl}" alt="Frame ${i + 1}">
<span class="frame-num">${i + 1}</span>
`;
grid.appendChild(item);
});
document.getElementById('previewCard').classList.remove('hidden');
document.getElementById('progressContainer').classList.add('hidden');
}
async downloadAll() {
if (this.frames.length === 0) return;
this.updateProgress(0, '打包中...');
document.getElementById('progressContainer').classList.remove('hidden');
const zip = new JSZip();
const folder = zip.folder('images');
this.frames.forEach((frame, i) => {
const frameNum = String(i + 1).padStart(3, '0');
const base64 = frame.dataUrl.split(',')[1];
folder.file(`car_${frameNum}.jpg`, base64, { base64: true });
this.updateProgress(
((i + 1) / this.frames.length) * 50,
`打包中... ${i + 1}/${this.frames.length}`
);
});
this.updateProgress(50, '生成 ZIP 文件...');
const blob = await zip.generateAsync({ type: 'blob' }, (metadata) => {
this.updateProgress(50 + metadata.percent / 2, '生成 ZIP 文件...');
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'car_frames.zip';
link.click();
this.updateProgress(100, '下载完成!');
setTimeout(() => {
document.getElementById('progressContainer').classList.add('hidden');
}, 2000);
}
async useFrames() {
if (this.frames.length === 0) return;
this.showStatus('success', '正在保存数据...');
const framesData = this.frames.map(f => f.dataUrl);
// 直接使用 IndexedDB 存储
try {
await this.saveToIndexedDB(framesData);
this.showStatus('success', '已保存!正在跳转到展示页面...');
setTimeout(() => {
window.location.href = 'index.html';
}, 500);
} catch (e) {
console.error('IndexedDB save failed:', e);
this.showStatus('error', '保存失败请尝试下载ZIP文件后手动解压到images文件夹');
}
}
saveToIndexedDB(frames) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('Car360DB', 1);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('frames')) {
db.createObjectStore('frames', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('frames', 'readwrite');
const store = tx.objectStore('frames');
store.put({ id: 'frames', data: frames });
tx.oncomplete = () => {
console.log('IndexedDB save complete');
resolve();
};
tx.onerror = () => reject(tx.error);
};
});
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
new FrameExtractor();
});
</script>
</body>
</html>

37
extract_frames.py Normal file
View File

@@ -0,0 +1,37 @@
import cv2
import os
# 配置
video_path = r'J:\git\3D-pano\video.mp4'
output_dir = r'J:\git\3D-pano\images'
total_frames = 336
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 打开视频
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print('Error: Cannot open video')
exit(1)
video_frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
print(f'Video total frames: {video_frame_count}')
# 计算帧间隔
interval = video_frame_count // total_frames
for i in range(total_frames):
frame_idx = i * interval
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if ret:
output_path = os.path.join(output_dir, f'car_{str(i+1).zfill(3)}.jpg')
cv2.imwrite(output_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
print(f'Saved: {output_path}')
else:
print(f'Failed to read frame {frame_idx}')
cap.release()
print(f'Done! Extracted {total_frames} frames to {output_dir}')

BIN
images/car_001.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_002.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_003.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_004.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_005.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_006.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_007.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_008.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_009.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_010.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_011.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
images/car_012.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_013.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_014.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_015.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
images/car_016.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/car_017.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
images/car_018.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/car_019.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/car_020.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_021.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
images/car_022.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
images/car_023.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_024.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_025.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/car_026.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_027.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_028.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_029.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_030.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
images/car_031.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
images/car_032.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
images/car_033.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
images/car_034.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
images/car_035.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
images/car_036.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
images/car_037.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
images/car_038.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
images/car_039.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
images/car_040.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
images/car_041.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
images/car_042.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
images/car_043.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
images/car_044.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
images/car_045.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
images/car_046.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
images/car_047.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_048.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_049.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
images/car_050.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_051.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_052.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
images/car_053.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_054.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_055.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_056.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_057.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_058.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_059.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_060.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_061.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_062.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_063.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_064.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_065.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_066.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
images/car_067.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
images/car_068.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_069.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
images/car_070.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
images/car_071.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
images/car_072.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_073.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_074.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_075.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_076.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_077.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_078.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_079.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_080.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
images/car_081.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_082.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
images/car_083.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
images/car_084.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
images/car_085.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
images/car_086.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
images/car_087.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
images/car_088.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
images/car_089.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_090.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_091.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
images/car_092.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
images/car_093.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_094.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
images/car_095.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/car_096.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Some files were not shown because too many files have changed in this diff Show More