新增 汽车360度全景展示移动端Web应用
- 实现触摸滑动旋转查看汽车360度全景 - 添加惯性滑动效果和自动旋转展示功能 - 实现全屏沉浸式体验和精美Loading动画 - 添加Python视频帧提取脚本和浏览器端提取工具 - 包含本地HTTP服务器和完整项目文档
118
README.md
Normal 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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_002.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_003.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_004.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_005.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_006.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_007.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_008.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_009.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_010.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_011.jpg
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
images/car_012.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_013.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_014.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_015.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
images/car_016.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/car_017.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
images/car_018.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/car_019.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/car_020.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_021.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
images/car_022.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
images/car_023.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_024.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_025.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
images/car_026.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_027.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_028.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_029.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_030.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
images/car_031.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
images/car_032.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
images/car_033.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
images/car_034.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
images/car_035.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
images/car_036.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
images/car_037.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
images/car_038.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/car_039.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/car_040.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/car_041.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/car_042.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
images/car_043.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
images/car_044.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
images/car_045.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/car_046.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/car_047.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_048.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_049.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
images/car_050.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_051.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_052.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
images/car_053.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_054.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_055.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_056.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_057.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_058.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_059.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_060.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_061.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_062.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_063.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_064.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_065.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_066.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/car_067.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
images/car_068.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_069.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/car_070.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/car_071.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
images/car_072.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_073.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_074.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_075.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_076.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_077.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_078.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_079.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_080.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
images/car_081.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_082.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/car_083.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/car_084.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
images/car_085.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/car_086.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
images/car_087.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/car_088.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
images/car_089.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_090.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_091.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
images/car_092.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
images/car_093.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_094.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/car_095.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
images/car_096.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |