Compare commits

...

67 Commits
dev ... main

Author SHA1 Message Date
Nicolas Bertrand 90bb0e69c0 Add button 2025-10-10 01:51:15 +02:00
Nicolas Bertrand 0cbc102508 Add msssing file 2025-10-10 00:42:52 +02:00
Nicolas Bertrand 7c5ab98553 Working 2025-10-10 00:42:36 +02:00
Nicolas Bertrand 5fba1ce900 First version running with One led 2025-10-09 20:01:45 +02:00
Nicolas Bertrand 35b039a39a update calibration 2025-10-09 19:44:31 +02:00
Nicolas Bertrand 80d259e519 1st version with led driver 2025-10-09 19:44:26 +02:00
Nicolas Bertrand 84a51af78d Updates Leds pages 2025-11-05 10:29:35 +01:00
Nicolas Bertrand 13c9bdfd25 Leds.html 2025-09-25 18:26:29 +02:00
Nicolas Bertrand e41f0b92d8 Add Leds 2025-09-25 18:26:01 +02:00
Nicolas Bertrand d75427eac1 Update preview 2025-09-18 10:33:55 +02:00
Nicolas Bertrand 9d40f213be HLS try 2025-09-18 09:44:24 +02:00
Nicolas Bertrand 7d9360576f Refactor camera configuration UI and enhance live preview functionality 2025-09-04 18:25:43 +02:00
Nicolas Bertrand 7ea52bc571 Display all params 2025-09-04 11:07:27 +02:00
Nicolas Bertrand 7eb06bbd57 WIP 2025-09-03 22:19:03 +02:00
Nicolas Bertrand 5c3bb99d52 Add Hardware 2024-11-25 15:46:49 +01:00
Nicolas Bertrand 8d1dff3470 Merge branch 'main' of ssh://git.polymny.net:55554/source/nenuscanner 2024-11-19 19:29:30 +01:00
Thomas Forgione 90866d01f8 Fix bug 2024-11-22 16:41:03 +01:00
Thomas Forgione 80ba6b40e8 Clean 2024-11-22 16:27:48 +01:00
Nicolas Bertrand ef8d988736 Resize preview 2024-11-19 19:29:05 +01:00
Nicolas Bertrand cbee1cb4b2 Clean 2024-11-19 18:34:43 +01:00
Nicolas Bertrand 76b13da56c Port 8000 2024-11-19 18:29:46 +01:00
Nicolas Bertrand 61c64d47fd Fix bugs 2024-11-19 17:49:16 +01:00
Nicolas Bertrand 2a33ad0b49 Update with hardware 2024-11-22 12:21:59 +01:00
Thomas Forgione f3e34ddc86 Merge branch 'main' of ssh://git.polymny.net:55554/source/nenuscanner 2024-11-22 09:46:20 +01:00
Thomas Forgione 732e24fd63 Systemctl 2024-11-22 09:46:12 +01:00
Thomas Forgione ec1798e150 Move schema 2024-11-21 19:45:09 +01:00
Thomas Forgione 48c3e66595 Update makefile 2024-11-21 15:44:30 +01:00
Thomas Forgione 6d955dd09a pyproject 2024-11-21 14:53:01 +01:00
Thomas Forgione bfe4a07be8 Better progress bar 2024-11-20 12:06:14 +01:00
Thomas Forgione 9eb3c6af0b DELAY, cleaning 2024-11-19 17:00:40 +01:00
Nicolas Bertrand 01f59f55f2 Fast mode 2024-11-19 16:48:11 +01:00
Thomas Forgione 9a64bc47cb Update makefile 2024-11-19 16:00:28 +01:00
Thomas Forgione ace1b4fd9a Merge branch 'main' of ssh://git.polymny.net:55554/source/nenuscanner 2024-11-19 16:00:24 +01:00
Nicolas Bertrand 745f95af8c Cleaning 2024-11-19 15:29:16 +01:00
Thomas Forgione 00789c63bd Merge branch 'main' of ssh://git.polymny.net:55554/source/nenuscanner 2024-11-19 15:20:05 +01:00
Thomas Forgione 727ab37b0b all_on and all_off 2024-11-19 11:34:21 +01:00
Thomas Forgione 62b6bb2417 Fix bug and colors of text 2024-11-18 15:53:32 +01:00
Thomas Forgione 8d13713c5f Fix 404 on reuse last calibration 2024-11-18 15:38:09 +01:00
Thomas Forgione 2e4238fc60 Easy testing with or without dark room setup 2024-11-18 09:49:22 +01:00
Nicolas Bertrand 3a63449c83 Add capture full on full off 2024-11-15 14:50:04 +01:00
Nicolas Bertrand 0acb2f0a0c Working version with RAW and JPEG 2024-11-15 14:14:53 +01:00
Nicolas Bertrand 6b4cb074eb update Readme.md 2024-11-15 11:48:34 +01:00
Nicolas Bertrand c02991eb44 Working with GPIO 2024-11-15 11:15:51 +01:00
Nicolas Bertrand 8cfcf1ec55 1st leds conversion 2024-11-12 15:26:08 +01:00
Nicolas Bertrand a658be3216 correct requirements.txt 2024-11-07 17:16:32 +01:00
Nicolas Bertrand 7e3787053a 1st Leds try 2024-11-07 17:16:32 +01:00
Nicolas Bertrand a01c73d228 Updates on camera config 2024-11-05 10:46:02 +01:00
Nicolas Bertrand e769611395 Add Readme 2024-11-05 09:51:26 +01:00
Nicolas Bertrand 9be861b01c Add camera for image shoot 2024-10-08 16:09:42 +02:00
Thomas Forgione d135d50f94 Fix broken links 2024-08-30 15:55:46 +02:00
Thomas Forgione 809d11cb49 Delete acqusition confirm popup 2024-08-30 15:15:57 +02:00
Thomas Forgione 68361ac92b Delete modal for delete object 2024-08-30 14:17:42 +02:00
Thomas Forgione f6f777662e Beautiful UI 2024-08-29 10:07:28 +02:00
Thomas Forgione 77f0c2efba Fix broken link 2024-08-26 09:47:57 +02:00
Thomas Forgione eddd0853b5 Cleaning 2024-08-23 16:19:38 +02:00
Thomas Forgione be9d8128d6 Docs 2024-08-23 15:18:26 +02:00
Thomas Forgione 84b5703f64 Working on cleaning 2024-08-23 15:17:09 +02:00
Thomas Forgione 4889aeaf7c Fix typo 2024-08-23 10:46:08 +02:00
Thomas Forgione d3f1253376 Cleaning 2024-08-23 10:17:44 +02:00
Thomas Forgione 38a92d4a73 Cleaner UI 2024-08-23 09:30:21 +02:00
Thomas Forgione e1c4d5179e Use last calibration when calibrating 2024-08-23 09:21:45 +02:00
Thomas Forgione 70383ebb34 Allow delete object 2024-08-22 16:20:14 +02:00
Thomas Forgione 6671f2ad15 Allow delete acquisition 2024-08-22 16:04:58 +02:00
Thomas Forgione 9913c394f3 Add support for projects 2024-08-22 14:03:54 +02:00
Thomas Forgione b7d53c381c Basic support for projects 2024-08-21 16:08:32 +02:00
Thomas Forgione 1c7f7e648a Fix bugs 2024-08-20 14:31:34 +02:00
Thomas Forgione 45401e3255 Factorize response, compute content length 2024-08-01 09:55:17 +02:00
52 changed files with 2809 additions and 783 deletions

13
.gitignore vendored
View File

@ -1,9 +1,12 @@
# Config file
config.py
.device
data
data-*
secret.py
src/nenuscanner/secret.py
src/nenuscanner/static/calibration-visualiser.*
node_modules
static/calibration-visualiser.*
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
@ -181,3 +184,9 @@ poetry.toml
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
# Custom ignores
src/nenuscanner/static/feed.jpg
db.sqlite

View File

@ -1,4 +1,4 @@
all: ts/*ts
if [ ! -f ./node_modules/typescript/bin/tsc ]; then npm install; fi
tsc
esbuild ts/main.ts --bundle --minify --sourcemap --target=firefox57 --outfile=static/calibration-visualiser.js
npm install
./node_modules/typescript/bin/tsc
./node_modules/esbuild/bin/esbuild ts/main.ts --bundle --minify --sourcemap --target=firefox58 --outfile=src/nenuscanner/static/calibration-visualiser.js

141
Readme.md Normal file
View File

@ -0,0 +1,141 @@
# NenuScanner
## Lancer l application flask depuis la racine
### Intall local de l applcation
Avoir un venv
puis
```
pip install -e .
```
dans src/nenuscanner
cp config.local.py dans config.py
### Lancer l'application
```
flask --app . run --debug
```
resoudre dependance si besoins genre:
```
pip install gpiod==2.1.3
```
## GPIO
### Cablage des LEDs existantes de la salle noire
| GPIO | ID LED salle noire |
| GPIO 17 | LED 2 |
| GPIO 18 | LED 3 |
| GPIO 27 | LED 5 |
| GPIO 23 | LED 6 |
| GPIO 23 | LED 6 |
## Appareil photo
### Réglages EOS 7D
Prise de photo:
```
gphoto2 --capture-image-and-download
```
#### Format image
Télécharger RAW et JPEG
```
gphoto2 --set-config imageformat=10
#Choice: 10 RAW + Small Fine JPEG
```
Liste des formats:
```
gphoto2 --get-config imageformat
```
#### Capture target
Stock sur la mémoire interne ou SD de l'apparail
Sur la mémoire innterne, au download c'est toujours le mêm fichier (écrasement)
```
gphoto2 --get-config capturetarget 0 ↵
Label: Capture Target
Readonly: 0
Type: RADIO
Current: Memory card
Choice: 0 Internal RAM
Choice: 1 Memory card
```
En prod choix 0 : on ne conserve pas les photos sur l'appareil
Et test choix 1 : on conserve sur l'appareil
Exemple choix 1:
```
gphoto2 --set-config capturetarget=1
```
# ISO
ISO 100
```
gphoto2 --set-config iso=1
```
### réglages objectif
| Ouverture | aperture | gphoto2 --set-config aperture=14 | Ouverture f/10 |
| temps de pose | shutterspeed | gphoto2 --set-config shutterspeed=35 | Vitesse 1/100 s |
| Picture Style | picturestyle | gphoto2 --set-config picturestyle=0 | Standard |
| Exposition | aeb | gphoto2 --set-config aeb=0 | 0 |
## Hardware
### LED
LED COB, Cree LED, série CXA2, 90CRI, 2700K Blanc, CXB1512-0000-000N0UK427G 22W
https://fr.rs-online.com/web/p/led-cob/8847355
### Connecteur
VsAT ID
TE connectivity 2-2154857-2
Arrow: https://www.arrow.com/en/products/2-2154857-2/te-connectivity
### Dissipateur
MeachaTronix LED heatsink series LPF_ZHC and LPF_ZHP
LPF6768-ZHP-B
https://www.elpro.org/gb/mechatronix-lpfzhc-and-lpfzhp-series/106105-lpf6768-zhp-b.html

327
app.py
View File

@ -1,327 +0,0 @@
#!/usr/bin/env python
from flask import Flask, redirect, request, render_template, send_from_directory, session
import itertools
import json
import os
from os.path import join
import sqlite3
import uuid
from . import db, config, scanner, calibration, archive
app = Flask(__name__)
# Manage secret key
try:
from . import secret
app.config['SECRET_KEY'] = secret.SECRET_KEY
except ImportError:
# Secret key file does not exist, create it
secret = os.urandom(50).hex()
with open('secret.py', 'w') as f:
f.write(f'SECRET_KEY = "{secret}"')
app.config['SECRET_KEY'] = secret
def get_calibration(conn: sqlite3.Connection) -> db.Calibration:
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
return db.Calibration.Dummy
return db.Calibration.get_from_id(calibration_id, conn)
@app.context_processor
def inject_stage_and_region():
conn = db.get()
return dict(calibration=get_calibration(conn), leds=config.LEDS_UUIDS, CalibrationState=db.CalibrationState)
@app.before_request
def manage_auto_use_last_calibration():
if config.AUTO_USE_LAST_CALIBRATION and 'calibration_id' not in session:
conn = db.get()
last = db.Calibration.get_last(conn)
if last is not None:
session['calibration_id'] = last.id
@app.route("/")
def index():
conn = db.get()
objects = db.Object.all(conn)
return render_template('index.html', objects=objects)
@app.route("/create-object/", methods=["POST"])
def create_object():
conn = db.get()
with conn:
db.Object.create(request.form.get('name'), conn)
return redirect('/')
@app.route('/object/<id>')
def object(id: int):
conn = db.get()
object = db.Object.get_from_id(id, conn).full(conn)
return render_template('object.html', object=object)
@app.route('/scan/<id>')
def scan(id: int):
conn = db.get()
calibration_id = session.get('calibration_id', None)
object = db.Object.get_from_id(id, conn)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
return render_template('scan.html', object=object)
@app.route('/scan-acquisition/<id>')
def scan_existing(id: int):
conn = db.get()
calibration_id = session.get('calibration_id', None)
acquisition = db.Acquisition.get_from_id(id, conn)
object = acquisition.object(conn)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
return render_template('scan.html', object=object, acquisition=acquisition)
@app.route("/calibrate/")
def calibrate():
conn = db.get()
if 'calibration_id' not in session:
with conn:
calibration = db.Calibration.create(conn)
session['calibration_id'] = calibration.id
else:
calibration = db.Calibration.get_from_id(session['calibration_id'], conn)
if calibration.state in [db.CalibrationState.Empty, db.CalibrationState.HasData]:
return render_template('calibrate.html')
else:
return render_template('calibration.html', calibration=calibration)
@app.route("/new-calibration")
def new_calibration():
conn = db.get()
with conn:
calibration = db.Calibration.create(conn)
session['calibration_id'] = calibration.id
return redirect('/calibrate')
@app.route("/cancel-calibration")
def cancel_calibration():
conn = db.get()
calibration = db.Calibration.get_from_id(session['calibration_id'], conn)
calibration.state = db.CalibrationState.HasData
with conn:
calibration.save(conn)
return redirect('/calibrate')
@app.route("/api/preview/<id>")
def preview(id: int):
conn = db.get()
db.Object.get_from_id(id, conn)
capture_uuid = uuid.uuid4()
if scanner.capture(join(config.DATA_DIR, str(id), 'previews', str(capture_uuid) + '.jpg')):
return str(capture_uuid)
else:
return "Impossible de capturer l'image.", 500
@app.route("/api/scan-for-calibration")
def scan_calibration():
conn = db.get()
if 'calibration_id' not in session:
with conn:
calibration = db.Calibration.create(conn)
calibration_id = str(calibration.id)
session['calibration_id'] = calibration.id
else:
calibration_id = str(session['calibration_id'])
calibration = get_calibration(conn)
def generate():
length = len(config.LEDS_UUIDS)
for index, led_uuid in enumerate(scanner.scan(join(config.CALIBRATION_DIR, calibration_id))):
yield f"{led_uuid},{(index+1)/length}\n"
with conn:
calibration.state = db.CalibrationState.HasData
calibration.save(conn)
return app.response_class(generate(), mimetype='text/plain')
@app.route("/api/scan-for-object/<object_id>")
def scan_object(object_id: int):
conn = db.get()
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
object = db.Object.get_from_id(object_id, conn)
if object is None:
raise RuntimeError(f"Aucun objet d'id {object_id}")
with conn:
acquisition = object.add_acquisition(calibration_id, conn)
def generate():
yield str(acquisition.id)
length = len(config.LEDS_UUIDS)
for index, led_uuid in enumerate(scanner.scan(join(config.OBJECT_DIR, str(object.id), str(acquisition.id)))):
yield f"{led_uuid},{(index+1)/length}\n"
return app.response_class(generate(), mimetype='text/plain')
@app.route("/api/scan-for-acquisition/<acquisition_id>")
def scan_acquisition(acquisition_id: int):
conn = db.get()
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
acquisition = db.Acquisition.get_from_id(acquisition_id, conn)
if acquisition is None:
raise RuntimeError(f"Aucun acquisition d'id {acquisition_id}")
object = acquisition.object(conn)
def generate():
length = len(config.LEDS_UUIDS)
for index, led_uuid in enumerate(scanner.scan(join(config.OBJECT_DIR, str(object.id), str(acquisition.id)))):
yield f"{led_uuid},{(index+1)/length}\n"
return app.response_class(generate(), mimetype='text/plain')
@app.route("/validate-acquisition/<acquisition_id>")
def validate_acquisition(acquisition_id: int):
conn = db.get()
acquisition = db.Acquisition.get_from_id(acquisition_id, conn)
if acquisition is None:
raise f"Aucune acquisition d'id {acquisition_id}"
object = acquisition.object(conn)
acquisition.validated = True
with conn:
acquisition.save(conn)
return redirect(f'/object/{object.id}')
@app.route('/api/use-last-calibration')
def use_last_calibration():
conn = db.get()
calibration = db.Calibration.get_last(conn)
session['calibration_id'] = calibration.id
return 'ok'
@app.route("/api/calibrate")
def run_calibration():
conn = db.get()
id = session['calibration_id']
calib = db.Calibration.get_from_id(id, conn)
if calib is None:
return 'oops', 404
calibration_json = calibration.calibrate(join(config.CALIBRATION_DIR, str(id)))
with open(join(config.CALIBRATION_DIR, str(id), 'calibration.json'), 'w') as f:
json.dump(calibration_json, f, indent=4)
with conn:
calib.state = db.CalibrationState.IsComputed
calib.save(conn)
return 'ok'
@app.route('/validate-calibration')
def validate_calibration():
conn = db.get()
calib = get_calibration(conn)
if calib is None:
return 'oops', 404
with conn:
calib.validate(conn)
return redirect('/')
def download_object(id: int, tar: archive.ArchiveSender):
conn = db.get()
object = db.Object.get_from_id(id, conn).full(conn)
# Group acquisitions sharing calibration
def keyfunc(x: db.Calibration) -> int:
return x.calibration_id
acquisitions_sorted = sorted(object.acquisitions, key=keyfunc)
acquisitions_grouped = [
(db.Calibration.get_from_id(k, conn), list(g))
for k, g in itertools.groupby(acquisitions_sorted, key=keyfunc)
]
# Create archive file to send
for calibration_index, (calib, acquisitions) in enumerate(acquisitions_grouped):
calibration_dir = join(config.CALIBRATION_DIR, str(calib.id))
# Add calibration images
for image in os.listdir(calibration_dir):
tar.add_file(
f'object/{calibration_index}/calibration/{image}',
join(calibration_dir, image)
)
# Add each acquisition
for acquisition_index, acquisition in enumerate(acquisitions):
acquisition_dir = join(config.OBJECT_DIR, str(object.id), str(acquisition.id))
for image in os.listdir(acquisition_dir):
tar.add_file(
f'object/{calibration_index}/{acquisition_index}/{image}',
join(acquisition_dir, image)
)
return tar.response()
@app.route('/download-object/tar/<id>')
def download_object_tar(id: int):
return download_object(id, archive.TarSender())
@app.route('/download-object/zip/<id>')
def download_object_zip(id: int):
return download_object(id, archive.ZipSender())
@app.route('/static/<path:path>')
def send_static(path):
return send_from_directory('static', path)
@app.route('/data/<path:path>')
def send_data(path):
return send_from_directory('data', path)

51
capture-image.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python
# python-gphoto2 - Python interface to libgphoto2
# http://github.com/jim-easterbrook/python-gphoto2
# Copyright (C) 2015-22 Jim Easterbrook jim@jim-easterbrook.me.uk
#
# This file is part of python-gphoto2.
#
# python-gphoto2 is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# python-gphoto2 is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with python-gphoto2. If not, see
# <https://www.gnu.org/licenses/>.
import logging
import locale
import os
import subprocess
import sys
import gphoto2 as gp
def main():
locale.setlocale(locale.LC_ALL, '')
logging.basicConfig(
format='%(levelname)s: %(name)s: %(message)s', level=logging.WARNING)
callback_obj = gp.check_result(gp.use_python_logging())
camera = gp.Camera()
camera.init()
print('Capturing image')
file_path = camera.capture(gp.GP_CAPTURE_IMAGE)
print('Camera file path: {0}/{1}'.format(file_path.folder, file_path.name))
target = os.path.join('/tmp', file_path.name)
print('Copying image to', target)
camera_file = camera.file_get(
file_path.folder, file_path.name, gp.GP_FILE_TYPE_NORMAL)
camera_file.save(target)
subprocess.call(['xdg-open', target])
camera.exit()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,24 +0,0 @@
from os.path import join
DATA_DIR = 'data'
BACKUPS_DIR = 'data-backups'
CALIBRATION_DIR = join(DATA_DIR, 'calibrations')
OBJECT_DIR = join(DATA_DIR, 'objects')
DATABASE_PATH = join(DATA_DIR, 'db.sqlite')
AUTO_USE_LAST_CALIBRATION = False
LEDS_UUIDS = [
'ac59350e-3787-46d2-88fa-743c1d34fe86',
'83ab3133-d4de-4a42-99a7-8f8a3f3732ba',
'577d6e72-f518-4d65-af28-f8faf1ca3f5d',
'ec49a20c-bddd-4614-b828-fa45e01bfb19',
'5c249cce-d2b6-4b56-96c8-5caa43d8f040',
'22d783fb-ae55-4581-a3c6-e010d9d5e9de',
'12cb6a32-04a6-433b-8146-b753b8f1286d',
'461255a3-259a-4291-adc3-2fb736231a04',
'3896662f-9826-4445-ad70-86c2e6c143e7',
'f87698ec-3cba-42fe-9a61-5ac9f77d767a',
'4c77a655-4b68-4557-a696-29345c6676a1',
'b1cfe287-aa3b-445e-bcdc-75ae375efe43',
]

15
nenuscanner.service Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=NenuScanner
After=network.target
[Service]
RestartSec=2s
Type=simple
User=pi
Group=pi
ExecStart=nenuscanner
Restart=always
[Install]
WantedBy=multi-user.target

440
package-lock.json generated
View File

@ -10,7 +10,393 @@
"license": "ISC",
"dependencies": {
"@types/three": "^0.165.0",
"three": "^0.165.0"
"esbuild": "^0.24.0",
"three": "^0.165.0",
"typescript": "^5.6.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
"integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz",
"integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz",
"integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz",
"integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz",
"integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz",
"integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz",
"integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz",
"integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz",
"integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz",
"integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz",
"integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz",
"integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz",
"integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz",
"integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz",
"integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz",
"integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz",
"integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz",
"integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz",
"integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz",
"integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz",
"integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz",
"integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz",
"integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz",
"integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@tweenjs/tween.js": {
@ -44,6 +430,45 @@
"integrity": "sha512-JYcclaQIlisHRXM9dMF7SeVvQ54kcYc7QK1eKCExCTLKWnZDxP4cp/rXH4Uoa1j5+5oQJ0Cc2sZC/PWiiG4q2g==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
"integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.0",
"@esbuild/android-arm": "0.24.0",
"@esbuild/android-arm64": "0.24.0",
"@esbuild/android-x64": "0.24.0",
"@esbuild/darwin-arm64": "0.24.0",
"@esbuild/darwin-x64": "0.24.0",
"@esbuild/freebsd-arm64": "0.24.0",
"@esbuild/freebsd-x64": "0.24.0",
"@esbuild/linux-arm": "0.24.0",
"@esbuild/linux-arm64": "0.24.0",
"@esbuild/linux-ia32": "0.24.0",
"@esbuild/linux-loong64": "0.24.0",
"@esbuild/linux-mips64el": "0.24.0",
"@esbuild/linux-ppc64": "0.24.0",
"@esbuild/linux-riscv64": "0.24.0",
"@esbuild/linux-s390x": "0.24.0",
"@esbuild/linux-x64": "0.24.0",
"@esbuild/netbsd-x64": "0.24.0",
"@esbuild/openbsd-arm64": "0.24.0",
"@esbuild/openbsd-x64": "0.24.0",
"@esbuild/sunos-x64": "0.24.0",
"@esbuild/win32-arm64": "0.24.0",
"@esbuild/win32-ia32": "0.24.0",
"@esbuild/win32-x64": "0.24.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -61,6 +486,19 @@
"resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz",
"integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@ -10,6 +10,8 @@
"description": "",
"dependencies": {
"@types/three": "^0.165.0",
"three": "^0.165.0"
"esbuild": "^0.24.0",
"three": "^0.165.0",
"typescript": "^5.6.3"
}
}

7
pyproject.toml Normal file
View File

@ -0,0 +1,7 @@
[project]
name = "nenuscanner"
version = "0.1.0"
dependencies = ["flask"]
[tool.setuptools.package-data]
nenuscanner = ["templates/*.html", "static/*"]

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
Flask
pillow
gphoto2
scipy
waitress

View File

@ -1,47 +0,0 @@
import cv2
import os
from os.path import join
import shutil
import time
from . import config
# Delay between to captures
DELAY = 0.5
def capture(output_path: str) -> bool:
try:
with open('.device', 'r') as f:
device_id = int(f.read().rstrip())
except:
device_id = 0
cam = cv2.VideoCapture(device_id)
s, img = cam.read()
if s:
cv2.imwrite(output_path, img)
cam.release()
return s
def scan(output_dir: str):
os.makedirs(output_dir, exist_ok=True)
for led in config.LEDS_UUIDS:
print(f'Turn on {led}')
img = join(output_dir, led + '.jpg')
# Measure the time it takes to capture
start = time.time()
# capture(img)
# For debug purposes
shutil.copyfile(join('data-keep/small', led + '.jpg'), img)
delta = time.time() - start
# Wait for at least one second between each capture
if delta < DELAY:
time.sleep(DELAY - delta)
print(f'Turn off {led}')
yield led

View File

@ -0,0 +1,57 @@
from flask import Flask, send_from_directory, session
import os
from . import db, config, routes, utils, leds
leds.get().enter()
app = Flask(__name__)
# Manage secret key
try:
from . import secret
app.config['SECRET_KEY'] = secret.SECRET_KEY
except ImportError:
# Secret key file does not exist, create it
secret = os.urandom(50).hex()
with open('secret.py', 'w') as f:
f.write(f'SECRET_KEY = "{secret}"')
app.config['SECRET_KEY'] = secret
# Middlewares to help us deal with stuff
@app.context_processor
def inject():
"""
Returns a dictionnary with the uuids of leds and the calibration state.
"""
conn = db.get()
return {
'calibration': utils.get_calibration(conn),
'leds': leds.get().leds,
'CalibrationState': db.CalibrationState,
}
@app.before_request
def manage_auto_use_last_calibration():
"""
Automatically use the last calibration if the config is set accordingly.
"""
if config.AUTO_USE_LAST_CALIBRATION and 'calibration_id' not in session:
conn = db.get()
last = db.Calibration.get_last(conn)
if last is not None:
session['calibration_id'] = last.id
app.register_blueprint(routes.blueprint)
@app.route('/static/<path:path>')
def send_static(path):
return send_from_directory('static', path)
@app.route('/data/<path:path>')
def send_data(path):
return send_from_directory(config.DATA_DIR, path)

View File

@ -0,0 +1,15 @@
#!/usr/bin/env python
import os
from . import app
from waitress import serve
def main():
port = os.environ.get('FLASK_RUN_PORT', 8000)
print(f'Starting server on port {port}')
serve(app, listen=f'*:{port}')
if __name__ == '__main__':
main()

View File

@ -4,6 +4,8 @@ from flask import Response
import functools
import os
import zlib
from typing import Optional
import time
# Chunks for crc 32 computation
CRC32_CHUNK_SIZE = 65_536
@ -19,6 +21,13 @@ ZERO = ord('0')
def tar_header_chunk(filename: str, filepath: str) -> bytes:
"""
Returns the 512 bytes header for a tar file in a tar archive.
Args:
filename (str): path of where the file will be in the archive.
filepath (str): path of where the file is currently on the disk.
"""
# Returns the octal representation without the initial
def oct(i: int) -> str:
@ -65,17 +74,76 @@ def tar_header_chunk(filename: str, filepath: str) -> bytes:
class ArchiveSender:
"""
Helper class to send archives over the network.
This class is abstract, and needs to be derived by specific archive sender classes.
"""
def __init__(self):
"""
Creates a new archive sender.
"""
self.files: dict[str, str] = {}
def add_file(self, filename: str, filepath: str):
"""
Adds a file to the archive.
Args:
filename (str): path of where the file will be in the archive.
filepath (str): path of where the file is currently on the disk.
"""
self.files[filename] = filepath
def response(self):
def content_length(self) -> Optional[int]:
"""
Returns the size of the archive if it is computable beforehand, none otherwise.
"""
return None
def generator(self):
"""
Returns a generator that yields the bytes of the archive.
"""
raise NotImplementedError("Abstract method")
def mime_type(self) -> str:
"""
Returns the mime type of the archive.
"""
raise NotImplementedError("Abstract method")
def archive_name(self) -> str:
"""
Returns the name of the archive.
This method is useful for web applications where the archive will be downloaded.
"""
raise NotImplementedError("Abstract method")
def response(self) -> Response:
"""
Returns a flask reponse for the archive.
"""
headers = {'Content-Disposition': f'attachment; filename="{self.archive_name()}"'}
length = self.content_length()
if length is not None:
headers['Content-Length'] = str(length)
return Response(
self.generator(),
mimetype=self.mime_type(),
headers=headers,
)
class TarSender(ArchiveSender):
"""
A sender for tar archives computed on the fly.
"""
def generator(self):
def generate():
for name, file in self.files.items():
@ -98,15 +166,31 @@ class TarSender(ArchiveSender):
yield b'\x00' * (512 - bytes_sent % 512)
return generate()
def response(self):
return Response(
self.generator(),
mimetype='application/x-tar',
headers={'Content-Disposition': 'attachment; filename="archive.tar"'}
)
def mime_type(self) -> str:
return 'application/x-tar'
def archive_name(self) -> str:
return 'archive.tar'
def content_length(self) -> int:
length = 0
for file in self.files.values():
stat = os.stat(file)
# Add size of header, and size of content ceiled to 512 bytes
length += 512 + stat.st_size + (512 - stat.st_size % 512)
return length
def crc32(filename) -> int:
"""
Computes the CRC32 checksum for the file.
Args:
filename (str): path to the file of which the CRC32 needs to be computed.
"""
with open(filename, 'rb') as fh:
hash = 0
while True:
@ -118,6 +202,17 @@ def crc32(filename) -> int:
def zip_local_file_header(filename: str, filepath: str, crc: int) -> bytes:
"""
Generates the bytes for the local file header of the file.
Args:
filename (str): path of where the file will be in the archive.
filepath (str): path of where the file is currently on the disk.
crc (int):
the CRC 32 checksum of the file. It is not computed by this function because it is also required in the
central directory file header, so the user of this function should compute it beforehand, and reuse it later
to avoid computing it twice.
"""
buffer_size = 30 + len(filename)
buffer = bytearray(buffer_size)
stat = os.stat(filepath)
@ -158,6 +253,18 @@ def zip_local_file_header(filename: str, filepath: str, crc: int) -> bytes:
def zip_central_directory_file_header(filename: str, filepath: str, crc: int, offset: int) -> bytes:
"""
Generates the bytes for the central directory file header of the file.
Args:
filename (str): path of where the file will be in the archive.
filepath (str): path of where the file is currently on the disk.
crc (int):
the CRC 32 checksum of the file. It is not computed by this function because it is also required in the
local file header, so the user of this function should compute it beforehand, and reuse it later to avoid
computing it twice.
offset (int): number of bytes where the file starts.
"""
buffer_size = 46 + len(filename)
buffer = bytearray(buffer_size)
stat = os.stat(filepath)
@ -212,6 +319,14 @@ def zip_central_directory_file_header(filename: str, filepath: str, crc: int, of
def zip_end_of_central_directory(items_number: int, central_directory_size: int, central_directory_offset: int):
"""
Generates the bytes for the end of central directory of the archive.
Args:
items_number (int): number of files in the archive.
central_directory_size (int): size in bytes of the central directory.
central_directory_offset (int): number of the byte where the central directory starts.
"""
buffer = bytearray(22)
# Field 1: End of central directory signature = 0x06054b50 (buffer[0:4])
buffer[0:4] = b'\x50\x4b\x05\x06'
@ -239,6 +354,10 @@ def zip_end_of_central_directory(items_number: int, central_directory_size: int,
class ZipSender(ArchiveSender):
"""
A sender for zip archives computed on the fly.
"""
def generator(self):
def generate():
local_offsets = dict()
@ -277,9 +396,20 @@ class ZipSender(ArchiveSender):
return generate()
def response(self):
return Response(
self.generator(),
mimetype='application/zip',
headers={'Content-Disposition': 'attachment; filename="archive.zip"'}
)
def content_length(self) -> int:
length = 0
for name, file in self.files.items():
stat = os.stat(file)
# Add size of local file header, central directory file header and file size
length += 76 + 2 * len(name) + stat.st_size
# Add size of end of central directory
return length + 22
def mime_type(self) -> str:
return 'application/zip'
def archive_name(self) -> str:
return 'archive.zip'

View File

@ -7,7 +7,7 @@ import os
import sys
from PIL import Image
from . import utils
from . import math_utils
# To extract a few images and resize them at 20% of their size:
@ -20,16 +20,19 @@ def print_error(msg: str):
def calibrate(input_dir: str):
# Load all images
image_names = list(filter(lambda x: x != 'calibration.json', sorted(os.listdir(input_dir))))
image_names = sorted([
x for x in os.listdir(input_dir)
if x != 'calibration.json' and x != 'all_on.jpg' and x != 'all_off.jpg' and x.endswith('.jpg')
])
images = [np.asarray(Image.open(os.path.join(input_dir, x))) for x in image_names]
# Camera parameters
nu, nv, nc = images[0].shape
nspheres = 5
nspheres = 4
focal_mm = 35
matrix_size = 24
focal_pix = nu * focal_mm / matrix_size
K = utils.build_K_matrix(focal_pix, nu/2, nv/2)
K = math_utils.build_K_matrix(focal_pix, nu/2, nv/2)
# Max image: image of brightest pixels, helps spheres segmentation
max_image = functools.reduce(np.maximum, images)
@ -38,22 +41,23 @@ def calibrate(input_dir: str):
pixels = np.reshape(max_image / 255.0, (-1, 3))
# Initialize parameters for GMM
init_params = np.ones(2), np.broadcast_to(np.eye(3) * 0.1, (2, 3, 3)), np.asarray([[0, 0, 0], [1, 1, 1]])
# init_params = np.ones(2), np.broadcast_to(np.eye(3) * 0.1, (2, 3, 3)), np.asarray([[0, 0, 0], [1, 1, 1]])
# Estimate GMM parameters and classify pixels
estimated_params = utils.gaussian_mixture_estimation(pixels, init_params, it=10)
classif = np.asarray(utils.maximum_likelihood(pixels, estimated_params), dtype=bool)
# estimated_params = math_utils.gaussian_mixture_estimation(pixels, init_params, it=10)
# classif = np.asarray(math_utils.maximum_likelihood(pixels, estimated_params), dtype=bool)
# Refine classification to select the appropriate binary mask
rectified_classif = utils.select_binary_mask(classif, lambda mask: np.mean(pixels[mask]))
# rectified_classif = math_utils.select_binary_mask(classif, lambda mask: np.mean(pixels[mask]))
rectified_classif = np.mean(pixels, axis=-1) > 0.03
# Identify the largest connected components (spheres) and extract their borders
sphere_masks = utils.get_greatest_components(np.reshape(rectified_classif, (nu, nv)), nspheres)
border_masks = np.vectorize(utils.get_mask_border, signature='(u,v)->(u,v)')(sphere_masks)
sphere_masks = math_utils.get_greatest_components(np.reshape(rectified_classif, (nu, nv)), nspheres)
border_masks = np.vectorize(math_utils.get_mask_border, signature='(u,v)->(u,v)')(sphere_masks)
# Fit quadratic forms (ellipses) to the borders
def fit_on_mask(border):
return utils.fit_quadratic_form(utils.to_homogeneous(np.argwhere(border)))
return math_utils.fit_quadratic_form(math_utils.to_homogeneous(np.argwhere(border)))
ellipse_quadratics = np.vectorize(fit_on_mask, signature='(u,v)->(t,t)')(border_masks)
@ -61,38 +65,38 @@ def calibrate(input_dir: str):
calibrated_quadratics = np.swapaxes(K, -1, -2) @ ellipse_quadratics @ K
# Deproject the ellipse quadratics to sphere centers
sphere_centers = utils.deproject_ellipse_to_sphere(calibrated_quadratics, 1)
sphere_centers = math_utils.deproject_ellipse_to_sphere(calibrated_quadratics, 1)
# Create coordinates and calculate camera rays
coordinates = np.stack(np.meshgrid(range(nu), range(nv), indexing='ij'), axis=-1)
rays = utils.get_camera_rays(coordinates, K)
rays = math_utils.get_camera_rays(coordinates, K)
# Find the intersections between the camera rays and the spheres
sphere_points_map, sphere_geometric_masks = \
utils.line_sphere_intersection(sphere_centers[:, np.newaxis, np.newaxis, :], 1, rays[np.newaxis, :, :, :])
math_utils.line_sphere_intersection(sphere_centers[:, np.newaxis, np.newaxis, :], 1, rays[np.newaxis, :, :, :])
sphere_points = np.asarray([sphere_points_map[i, sphere_geometric_masks[i]] for i in range(nspheres)], dtype=object)
sphere_normals = np.vectorize(utils.sphere_intersection_normal, signature='(v),()->()', otypes=[object])(sphere_centers, sphere_points)
sphere_normals = np.vectorize(math_utils.sphere_intersection_normal, signature='(v),()->()', otypes=[object])(sphere_centers, sphere_points)
# Load grey values from images for the identified sphere regions
def to_grayscale(image):
return [np.mean(image, axis=-1)[sphere_geometric_masks[i]] / 255.0 for i in range(nspheres)]
return [np.power(np.mean(image, axis=-1)[sphere_geometric_masks[i]] / 255.0, 1.0) for i in range(nspheres)]
grey_values = np.asarray(list(map(to_grayscale, images)), dtype=object)
# Estimate lighting conditions from sphere normals and grey values
estimated_lights = np.vectorize(
utils.estimate_light,
math_utils.estimate_light,
excluded=(2,),
signature='(),()->(k)',
otypes=[float]
)(sphere_normals, grey_values, (0.1, 0.9))
# Calculate the positions of the light sources
light_positions = utils.lines_intersections(sphere_centers, estimated_lights)
light_positions = math_utils.lines_intersections(sphere_centers, estimated_lights)
# Calculate plane parameters from the sphere centers and intersect camera rays with the plane
plane_normal, plane_alpha = utils.plane_parameters_from_points(sphere_centers)
plane_normal, plane_alpha = math_utils.plane_parameters_from_points(sphere_centers)
# Return value as dictionnary
return {

177
src/nenuscanner/camera.py Normal file
View File

@ -0,0 +1,177 @@
import subprocess
from flask import jsonify
import gphoto2 as gp
import shutil
from . import leds, config
import subprocess
import json
from PIL import Image
import io
def parse_config(lines):
config = {}
block = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith('/main/'):
# Nouveau bloc : traiter le précédent s'il existe
if block:
insert_block(config, block)
block = []
block.append(line)
elif line == 'END':
block.append(line)
insert_block(config, block)
block = []
else:
block.append(line)
return config
def insert_block(config, block):
path = block[0].strip('/').split('/') # ['main', 'actions', 'syncdatetimeutc']
data = {}
for line in block[1:-1]: # Exclure le chemin et 'END'
if ':' in line:
key, value = line.split(':', 1)
key = key.strip()
value = value.strip()
if key.startswith('Choice'):
# Extrait les choix dans une liste
idx, label = value.split(' ', 1)
data.setdefault('Choices', []).append({'id': int(idx), 'label': label})
else:
data[key] = value
# Insère dans le dictionnaire hiérarchique
d = config
for part in path[:-1]: # Traverse les sections, ex: main -> actions
d = d.setdefault(part, {})
d[path[-1]] = data # Attribue les données à la clé finale
class Camera:
def capture(self):
return None
def config(self):
return None
class RealCamera(Camera):
def __init__(self):
self._entered = False
self.inner = gp.Camera()
def __enter__(self):
if not self._entered:
self._entered = True
self.inner.init()
return self
def __exit__(self, *args):
# self.inner.exit()
pass
def capture(self):
try:
return self.inner.capture(gp.GP_CAPTURE_IMAGE)
except Exception as e:
print('An error occured when capturing photo', e)
return None
def capture_preview(self):
capture = gp.check_result(gp.gp_camera_capture_preview(self.inner))
file_data = gp.check_result(gp.gp_file_get_data_and_size(capture))
data = memoryview(file_data)
image = Image.open(io.BytesIO(file_data))
image.save("src/nenuscanner/static/feed.jpg")
def save(self, capture, output_file):
preview = self.inner.file_get(capture.folder, capture.name[:-3] + 'JPG', gp.GP_FILE_TYPE_NORMAL)
raw = self.inner.file_get(capture.folder, capture.name, gp.GP_FILE_TYPE_RAW)
preview.save(output_file + '.jpg')
# Resize preview
subprocess.run(['convert', output_file + '.jpg', '-resize', '10%', output_file + '.jpg'])
raw.save(output_file + '.cr2')
def config(self):
was_entered = self._entered
if self._entered:
self._entered = False
self.inner.exit()
res = subprocess.run(["gphoto2", "--list-all-config"], capture_output=True, encoding="utf-8")
if was_entered:
self.__enter__()
# print(res.stdout[:200])
configs = res.stdout.split("\n")
print(configs)
config_dict = parse_config(configs)
# Sauvegarde en JSON
with open("configCamera.json", "w", encoding="utf-8") as f:
json.dump(config_dict, f, indent=2, ensure_ascii=False)
def set_config(self, parameter, value):
subprocess.run(["gphoto2", "--set-config", f"{parameter}={value}"])
return 0
class DummyCamera(Camera):
def __init__(self, leds: leds.DummyLeds):
self.leds = leds
def __enter__(self):
return self
def __exit__(self, *args):
pass
def capture(self):
# Find which leds are turned on
found = None
all_on = False
for led in self.leds.leds:
if led.is_on:
if found is None:
found = led
else:
all_on = True
if all_on:
return 'data-keep/small/all_on.jpg'
elif found is not None:
return 'data-keep/small/' + str(found) + '.jpg'
else:
return 'data-keep/small/all_off.jpg'
def save(self, capture, output_file):
shutil.copyfile(capture, output_file + '.jpg')
camera = DummyCamera(leds.get()) if config.CAMERA == "dummy" else RealCamera()
def get():
return camera
def config():
return camera.config()
def set_config(parameter, value):
return camera.set_config(parameter, value)
class CameraException(Exception):
"""Exception personnalisée pour les erreurs liées à la caméra."""
def __init__(self, message):
super().__init__(message)

View File

@ -0,0 +1,13 @@
from os.path import join
DATA_DIR = 'data'
BACKUPS_DIR = 'data-backups'
CALIBRATION_DIR = join(DATA_DIR, 'calibrations')
OBJECT_DIR = join(DATA_DIR, 'objects')
DATABASE_PATH = join(DATA_DIR, 'db.sqlite')
AUTO_USE_LAST_CALIBRATION = True
DELAY = None
GPIO_CHIP = 'gpiochip0'
LEDS_UUIDS = [17, 18, 22, 23, 24, 27]
CAMERA = 'real'

View File

@ -0,0 +1,13 @@
from os.path import join
DATA_DIR = 'data'
BACKUPS_DIR = 'data-backups'
CALIBRATION_DIR = join(DATA_DIR, 'calibrations')
OBJECT_DIR = join(DATA_DIR, 'objects')
DATABASE_PATH = join(DATA_DIR, 'db.sqlite')
AUTO_USE_LAST_CALIBRATION = False
DELAY = 0.5
GPIO_CHIP = None
LEDS_UUIDS = [17, 18, 22, 23, 24, 27]
CAMERA = 'dummy'

View File

@ -3,6 +3,7 @@
from enum import IntEnum
from datetime import datetime
from flask import g
import itertools
import os
from os.path import join
import shutil
@ -171,28 +172,37 @@ class Acquisition:
def get_pretty_date(self) -> str:
return dateutils.format(self.date)
@staticmethod
def delete_from_id(acquisition_id: int, db: sqlite3.Connection) -> 'Acquisition':
cur = db.cursor()
response = cur.execute(
'DELETE FROM acquisition WHERE id = ? RETURNING ' + Acquisition.select_args() + ';',
[acquisition_id]
)
return Acquisition.from_row(response.fetchone())
class FullObject:
def __init__(self, object_id: int, name: str, acquisitions: list[Acquisition]):
self.id = object_id
class Project:
def __init__(self, name: str, objects: list['Object']):
self.name = name
self.acquisitions = acquisitions
self.objects = objects
class Object:
@staticmethod
def select_args() -> str:
return 'id, name'
return 'id, name, project'
def __init__(self, object_id: int, name: str):
def __init__(self, object_id: int, name: str, project: str):
self.id = object_id
self.name = name
self.project = project
def save(self, db: sqlite3.Connection):
cur = db.cursor()
cur.execute(
'UPDATE object SET name = ? WHERE id = ?',
[self.name, self.id]
'UPDATE object SET name = ?, project = ? WHERE id = ?',
[self.name, self.project, self.id]
)
@staticmethod
@ -202,11 +212,11 @@ class Object:
return Object(*row)
@staticmethod
def create(name: str, db: sqlite3.Connection) -> 'Object':
def create(name: str, project: str, db: sqlite3.Connection) -> 'Object':
cur = db.cursor()
response = cur.execute(
'INSERT INTO object(name) VALUES (?) RETURNING ' + Object.select_args() + ';',
[name]
'INSERT INTO object(name, project) VALUES (?, ?) RETURNING ' + Object.select_args() + ';',
[name, project]
)
object = Object.from_row(response.fetchone())
os.makedirs(join(config.OBJECT_DIR, str(object.id)))
@ -230,6 +240,12 @@ class Object:
)
return list(map(Object.from_row, response.fetchall()))
@staticmethod
def all_by_project(db: sqlite3.Connection) -> list[Project]:
objects = [x.full(db) for x in Object.all(db)]
objects_by_projects = itertools.groupby(objects, lambda x: x.project)
return list(map(lambda x: Project(x[0], list(x[1])), objects_by_projects))
def add_acquisition(self, calibration_id: int, db: sqlite3.Connection) -> Acquisition:
cur = db.cursor()
response = cur.execute(
@ -238,14 +254,32 @@ class Object:
)
return Acquisition.from_row(response.fetchone())
def full(self, db: sqlite3.Connection) -> FullObject:
@staticmethod
def delete_from_id(object_id: int, db: sqlite3.Connection) -> 'Object':
cur = db.cursor()
response = cur.execute(
'DELETE FROM object WHERE id = ? RETURNING ' + Object.select_args() + ';',
[object_id]
)
return Object.from_row(response.fetchone())
def full(self, db: sqlite3.Connection) -> 'FullObject':
cur = db.cursor()
response = cur.execute(
'SELECT ' + Acquisition.select_args() + ' FROM acquisition WHERE object_id = ? ORDER BY date DESC;',
[self.id]
)
acquisitions = list(map(lambda x: Acquisition.from_row(x), response.fetchall()))
return FullObject(self.id, self.name, acquisitions)
return FullObject(self.id, self.name, self.project, acquisitions)
class FullObject(Object):
def __init__(self, object_id: int, name: str, project: str, acquisitions: list[Acquisition]):
super().__init__(object_id, name, project)
self.id = object_id
self.name = name
self.project = project
self.acquisitions = acquisitions
def main():
@ -268,28 +302,6 @@ def main():
db.row_factory = sqlite3.Row
init(db)
# Create a new object
# with db:
# Object.create('Mon premier objet', db)
# # calibration = Calibration.create(db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # calibration = Calibration.create(db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# Object.create('Mon deuxième objet', db)
# # calibration = Calibration.create(db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # calibration = Calibration.create(db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
# # object.add_acquisition(calibration.id, db)
if __name__ == '__main__':
main()

150
src/nenuscanner/leds.py Normal file
View File

@ -0,0 +1,150 @@
import gpiod
from . import config
class Leds:
def __enter__(self):
pass
def __exit__(self, *args):
pass
def on(self):
pass
def off(self):
pass
class GpioLed:
def __init__(self, gpio_pin: int):
self.gpio_pin = gpio_pin
self.led = None
def enter(self, chip: gpiod.Chip):
self.led = chip.get_line(self.gpio_pin)
self.led.request(consumer=str(self), type=gpiod.LINE_REQ_DIR_OUT)
self.off()
def exit(self):
self.off()
self.led.release()
self.led = None
def on(self):
self.led.set_value(0)
def off(self):
self.led.set_value(1)
def __str__(self):
return f'LED{self.gpio_pin:02}'
class GpioLeds(Leds):
def __init__(self, chip: str, gpio_pins: list[int]):
self._entered = False
self.chip = gpiod.Chip(chip)
self.leds = []
for pin in gpio_pins:
self.leds.append(GpioLed(pin))
def __enter__(self):
if not self._entered:
self._entered = True
for led in self.leds:
led.enter(self.chip)
return self
def __exit__(self, *args):
# for led in self.leds:
# led.exit()
# self._entered = False
pass
def off(self):
for led in self.leds:
led.off()
def on(self):
for led in self.leds:
led.on()
def enter(self):
return self.__enter__()
def exit(self,*args):
self.__exit__(*args)
def get_by_uuid(self, uuid: int) -> GpioLed:
for led in self.leds:
if led.gpio_pin == uuid:
return led
raise ValueError(f"No LED with UUID {uuid}")
class DummyLed:
def __init__(self, gpio_pin: int):
self.gpio_pin = gpio_pin
self.is_on = False
def enter(self):
pass
def exit(self):
pass
def on(self):
self.is_on = True
def off(self):
self.is_on = False
def __str__(self):
return f'LED{self.gpio_pin:02}'
class DummyLeds(Leds):
def __init__(self, gpio_pins: list[int]):
self.leds = []
for pin in gpio_pins:
self.leds.append(DummyLed(pin))
def __enter__(self):
for led in self.leds:
led.enter()
return self
def __exit__(self, *args):
for led in self.leds:
led.exit()
def off(self):
for led in self.leds:
led.off()
def on(self):
for led in self.leds:
led.on()
def enter(self):
return self.__enter__()
def exit(self,*args):
self.__exit__(*args)
def get_by_uuid(self, uuid: int) -> DummyLed:
for led in self.leds:
if led.gpio_pin == uuid:
return led
raise ValueError(f"No LED with UUID {uuid}")
_leds = GpioLeds(config.GPIO_CHIP, config.LEDS_UUIDS) if config.GPIO_CHIP is not None else DummyLeds(config.LEDS_UUIDS)
def get() -> Leds:
return _leds

View File

@ -0,0 +1,40 @@
import subprocess
from flask import Blueprint, render_template
from .. import db
from . import object, calibration, acquisition, camera, leds
blueprint = Blueprint('routes', __name__)
# Generic routes
@blueprint.route("/")
def index():
"""
Serves the index of nenuscanner.
"""
conn = db.get()
projects = db.Object.all_by_project(conn)
return render_template('index.html', projects=projects)
# Route that restarts the server
@blueprint.route("/restart")
def restart():
"""
Serves the index of nenuscanner.
"""
subprocess.Popen(
["bash", "-c", "sleep 1 && systemctl restart nenuscanner --user"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return render_template('restart.html')
blueprint.register_blueprint(object.blueprint, url_prefix='/object')
blueprint.register_blueprint(calibration.blueprint, url_prefix='/calibration')
blueprint.register_blueprint(acquisition.blueprint, url_prefix='/acquisition')
blueprint.register_blueprint(camera.blueprint, url_prefix='/camera')
blueprint.register_blueprint(leds.blueprint, url_prefix='/leds')

View File

@ -0,0 +1,104 @@
from flask import Blueprint, Response, render_template, session, redirect
from os.path import join
from .. import db, config, scanner
blueprint = Blueprint('acquisition', __name__)
@blueprint.route('/scan/<id>')
def scan(id: int):
"""
Route to scan an object
"""
conn = db.get()
calibration_id = session.get('calibration_id', None)
object = db.Object.get_from_id(id, conn)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
return render_template('scan.html', object=object, calibrated=True)
@blueprint.route('/rescan/<id>')
def scan_existing(id: int):
conn = db.get()
calibrated = session.get('calibration_id', None) is not None
acquisition = db.Acquisition.get_from_id(id, conn)
object = acquisition.object(conn) if acquisition is not None else None
return render_template('scan.html', object=object, acquisition=acquisition, calibrated=calibrated)
@blueprint.route('/run/<object_id>')
def run(object_id: int):
conn = db.get()
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
object = db.Object.get_from_id(object_id, conn)
if object is None:
raise RuntimeError(f"Aucun objet d'id {object_id}")
with conn:
acquisition = object.add_acquisition(calibration_id, conn)
def generate():
yield str(acquisition.id)
for value in scanner.scan(join(config.OBJECT_DIR, str(object.id), str(acquisition.id))):
yield value
return Response(generate(), mimetype='text/plain')
@blueprint.route('/rerun/<acquisition_id>')
def rescan(acquisition_id: int):
"""
Route to relaunch an acquisition
"""
conn = db.get()
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
raise RuntimeError("Impossible de faire l'acquisition sans étalonnage")
acquisition = db.Acquisition.get_from_id(acquisition_id, conn)
if acquisition is None:
raise RuntimeError(f"Aucun acquisition d'id {acquisition_id}")
object = acquisition.object(conn)
def generate():
for value in scanner.scan(join(config.OBJECT_DIR, str(object.id), str(acquisition.id))):
yield value
return Response(generate(), mimetype='text/plain')
@blueprint.route('/validate/<acquisition_id>')
def validate(acquisition_id: int):
conn = db.get()
acquisition = db.Acquisition.get_from_id(acquisition_id, conn)
if acquisition is None:
raise Exception(f"Aucune acquisition d'id {acquisition_id}")
object = acquisition.object(conn)
acquisition.validated = True
with conn:
acquisition.save(conn)
return redirect(f'/object/{object.id}')
@blueprint.route("/delete/<acquisition_id>")
def delete(acquisition_id: int):
conn = db.get()
with conn:
acqusition = db.Acquisition.delete_from_id(acquisition_id, conn)
return redirect('/object/' + str(acqusition.object_id))

View File

@ -0,0 +1,127 @@
from flask import Blueprint, Response, render_template, redirect, session
from os.path import join
import json
from .. import db, utils, scanner, config, calibration
blueprint = Blueprint('calibration', __name__)
@blueprint.route("/create")
def create():
"""
Creates a new calibration and redirects to the page to calibrate.
"""
conn = db.get()
with conn:
calibration = db.Calibration.create(conn)
session['calibration_id'] = calibration.id
return redirect('/calibration/calibrate')
@blueprint.route("/calibrate")
def calibrate():
"""
Returns the page to calibrate the system.
"""
conn = db.get()
if 'calibration_id' not in session:
with conn:
calibration = db.Calibration.create(conn)
session['calibration_id'] = calibration.id
else:
calibration = db.Calibration.get_from_id(session['calibration_id'], conn)
if calibration.state in [db.CalibrationState.Empty, db.CalibrationState.HasData]:
return render_template('calibrate.html')
else:
return render_template('calibration.html', calibration=calibration)
@blueprint.route('/scan')
def scan():
"""
Starts a scan for calibration.
"""
conn = db.get()
if 'calibration_id' not in session:
with conn:
calibration = db.Calibration.create(conn)
calibration_id = str(calibration.id)
session['calibration_id'] = calibration.id
else:
calibration_id = str(session['calibration_id'])
calibration = utils.get_calibration(conn)
def generate():
for value in scanner.scan(join(config.CALIBRATION_DIR, calibration_id), False):
yield value
with conn:
calibration.state = db.CalibrationState.HasData
calibration.save(conn)
return Response(generate(), mimetype='text/plain')
@blueprint.route('/compute')
def compute():
"""
Compute the calibration from the scan.
"""
conn = db.get()
id = session['calibration_id']
calib = db.Calibration.get_from_id(id, conn)
if calib is None:
return 'oops', 404
calibration_json = calibration.calibrate(join(config.CALIBRATION_DIR, str(id)))
with open(join(config.CALIBRATION_DIR, str(id), 'calibration.json'), 'w') as f:
json.dump(calibration_json, f, indent=4)
with conn:
calib.state = db.CalibrationState.IsComputed
calib.save(conn)
return 'ok'
@blueprint.route('/cancel')
def cancel():
"""
Cancels a calibration.
"""
conn = db.get()
calibration = db.Calibration.get_from_id(session['calibration_id'], conn)
calibration.state = db.CalibrationState.HasData
with conn:
calibration.save(conn)
return redirect('/calibration/calibrate')
@blueprint.route('/validate')
def validate():
"""
Validates a calibration.
"""
conn = db.get()
calib = utils.get_calibration(conn)
if calib is None:
return 'oops', 404
with conn:
calib.validate(conn)
return redirect('/')
@blueprint.route('/use-last')
def use_last():
"""
Sets the current calibration to the last one that was validated.
"""
conn = db.get()
calib = db.Calibration.get_last(conn)
session['calibration_id'] = calib.id
return redirect('/calibration/calibrate')

View File

@ -0,0 +1,107 @@
from flask import Blueprint, render_template, request, send_file, jsonify
import json
import subprocess
from .. import camera as C
blueprint = Blueprint('camera', __name__)
# Routes for object management
@blueprint.route('/')
def get():
"""
Returns the page showing camera configuration for all parameters in capturesettings and imgsettings,
grouped by section.
"""
return render_template(
'camera.html')
@blueprint.route('/set', methods=['POST'])
def set_camera_settings():
"""
Receives and processes new camera settings for all parameters from the client.
"""
data = request.get_json()
updated = {}
for key, value in data.items():
print(f"Received {key}: {value}")
C.set_config(key, value)
updated[key] = value
try:
cam = C.get()
cam.capture_preview()
return jsonify({'status': 'ok'})
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500
return {'status': 'ok', **updated}
@blueprint.route('/feed.jpg', methods=['GET'])
def camera_feed():
capture_preview()
return send_file('static/feed.jpg', mimetype='image/jpeg')
@blueprint.route('/config', methods=['GET'])
def get_camera_config():
"""
Returns grouped camera parameters as JSON for frontend JS.
"""
try:
cam = C.get()
cam.config()
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500
with open('configCamera.json', 'r') as f:
config = json.load(f)
grouped_params = []
# Iterate over all sections in config['main']
for section, settings in config['main'].items():
section_params = []
if isinstance(settings, dict):
for param_name, param in settings.items():
if 'Choices' in param and isinstance(param['Choices'], list) and param['Choices']:
choices = [
{'value': c.get('id', idx), 'label': c['label']}
for idx, c in enumerate(param['Choices'])
]
section_params.append({
'name': param_name,
'label': param.get('Label', param_name.capitalize()),
'choices': choices,
'current': param.get('Current', ''),
'Type': param.get('Type', 'Text'),
'Readonly': param.get('Readonly', 0)
})
if section_params:
grouped_params.append({
'section': section,
'params': section_params
})
return jsonify(grouped_params)
# @blueprint.route('/capture_preview', methods=['POST'])
def capture_preview():
"""
Capture un aperçu avec gphoto2 et sauvegarde dans static/feed.jpg
"""
try:
with C.get() as cam:
cam.capture_preview()
return jsonify({'status': 'ok'})
except C.CameraException as e:
return jsonify({'status': 'error', 'error': str(e)}), 500

View File

@ -0,0 +1,54 @@
from flask import Blueprint, render_template, request, send_file, jsonify, session, current_app
import json
import subprocess
import gpiod
from .. import camera as C
from .. import leds,config
blueprint = Blueprint('leds', __name__)
# Routes for object management
@blueprint.route('/')
def get():
"""
Returns the pages showing all leds.
"""
return render_template(
'leds.html', leds= config.LEDS_UUIDS)
@blueprint.route('/set', methods=['POST'])
def set_led():
"""
Reçoit une commande pour allumer ou éteindre une LED.
Attend un JSON : { "led": "14", "state": "on" } ou { "led": "15, "state": "off" }
"""
data = request.get_json()
led = data.get('led')
state = data.get('state')
# get the controller (lazy, stored on app.extensions)
gpio_leds = leds.get()
try:
# parse led id/name according to your naming convention
print([x.gpio_pin for x in gpio_leds.leds])
gpio_led = gpio_leds.get_by_uuid(int(led))
print(f"Setting {led} / {gpio_led} to {state}")
if state == "on":
gpio_led.on()
else:
gpio_led.off()
except Exception as e:
raise
print(f'{e}')
return jsonify({'status': 'error', 'error': 'error'}), 400
print(f"Commande reçue pour {led} : {state}")
return jsonify({'status': 'ok', 'led': led, 'state': state})

View File

@ -0,0 +1,98 @@
from flask import Blueprint, render_template, redirect, request
import os
from os.path import join
import itertools
from .. import db, config, archive
blueprint = Blueprint('routes', __name__)
# Routes for object management
@blueprint.route('/<id>')
def get(id: int):
"""
Returns the page showing an object.
"""
conn = db.get()
object = db.Object.get_from_id(id, conn).full(conn)
return render_template('object.html', object=object)
@blueprint.route('/create', methods=['POST'])
def create():
"""
Creates a new object.
"""
conn = db.get()
with conn:
db.Object.create(request.form.get('name'), request.form.get('project'), conn)
return redirect('/')
@blueprint.route('/delete/<id>')
def delete(id: int):
"""
Deletes an object from its id.
"""
conn = db.get()
with conn:
db.Object.delete_from_id(id, conn)
return redirect('/')
def download_object(id: int, archive: archive.ArchiveSender):
"""
Helper for routes that send archives.
"""
conn = db.get()
object = db.Object.get_from_id(id, conn).full(conn)
# Group acquisitions sharing calibration
def keyfunc(x: db.Calibration) -> int:
return x.calibration_id
acquisitions_sorted = sorted(object.acquisitions, key=keyfunc)
acquisitions_grouped = [
(db.Calibration.get_from_id(k, conn), list(g))
for k, g in itertools.groupby(acquisitions_sorted, key=keyfunc)
]
# Create archive file to send
for calibration_index, (calib, acquisitions) in enumerate(acquisitions_grouped):
calibration_dir = join(config.CALIBRATION_DIR, str(calib.id))
# Add calibration images
for image in os.listdir(calibration_dir):
archive.add_file(
f'object/{calibration_index}/calibration/{image}',
join(calibration_dir, image)
)
# Add each acquisition
for acquisition_index, acquisition in enumerate(acquisitions):
acquisition_dir = join(config.OBJECT_DIR, str(object.id), str(acquisition.id))
for image in os.listdir(acquisition_dir):
archive.add_file(
f'object/{calibration_index}/{acquisition_index}/{image}',
join(acquisition_dir, image)
)
return archive.response()
@blueprint.route('/download/tar/<id>')
def download_object_tar(id: int):
"""
Downloads an object as a tar archive.
"""
return download_object(id, archive.TarSender())
@blueprint.route('/download/zip/<id>')
def download_object_zip(id: int):
"""
Downloads an object as a zip archive.
"""
return download_object(id, archive.ZipSender())

View File

@ -0,0 +1,67 @@
import os
from os.path import join
import time
from . import leds, camera, config
def delay_capture(cam):
# Measure the time it takes to capture
start = time.time()
output = cam.capture()
delta = time.time() - start
# Wait for at least one second between each capture
if config.DELAY is not None and delta < config.DELAY:
time.sleep(config.DELAY - delta)
return output
def delay_save(cam, source, target):
# Measure the time it takes to save
start = time.time()
cam.save(source, target)
delta = time.time() - start
# Wait for at least one second between each save
if config.DELAY is not None and delta < config.DELAY:
time.sleep(config.DELAY - delta)
def scan(output_dir: str, on_and_off: bool = True):
os.makedirs(output_dir, exist_ok=True)
file_paths = []
length = len(config.LEDS_UUIDS) + (2 if on_and_off else 0)
with camera.get() as cam:
with leds.get() as gpio_leds:
for count, led in enumerate(gpio_leds.leds):
print(f'Turn on {led}')
led.on()
file_paths.append((str(led), delay_capture(cam)))
led.off()
print(f'Turn off {led}')
ratio = (count + 1) / (2 * length)
yield f'{{ "status": "captured", "id": "{led}", "ratio": {ratio:.3} }}\n'
# capture with all leds ON OFF
if on_and_off:
gpio_leds.on()
file_paths.append(('all_on', delay_capture(cam)))
ratio = (length - 1) / (2 * length)
yield f'{{ "status": "captured", "id": "all_on", "ratio": {ratio:.3} }}\n'
gpio_leds.off()
file_paths.append(('all_off', delay_capture(cam)))
ratio = 0.5
yield f'{{ "status": "captured", "id": "all_off", "ratio": {ratio:.3} }}\n'
with camera.get() as cam:
for count, (target, source) in enumerate(file_paths):
delay_save(cam, source, join(output_dir, target))
ratio = 0.5 + (count + 1) / (2 * length)
yield f'{{ "status": "ready", "id": "{target}", "ratio": {ratio:.3} }}\n'

View File

@ -15,10 +15,11 @@ CREATE TABLE acquisition (
date INTEGER NOT NULL,
validated INT NOT NULL,
CONSTRAINT fk_calibration FOREIGN KEY(calibration_id) REFERENCES calibration(id),
CONSTRAINT fk_object FOREIGN KEY(object_id) REFERENCES object(id)
CONSTRAINT fk_object FOREIGN KEY(object_id) REFERENCES object(id) ON DELETE CASCADE
);
CREATE TABLE object (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
name TEXT NOT NULL
);

View File

@ -0,0 +1,59 @@
.cameraSettings {
height: 800px;
border: 1px solid;
overflow: scroll;
scrollbar-color: red orange;
scrollbar-width: thin;
}
img#preview {
width: 640px;
height: 480px;
border: 1px solid black;
}
.switch-slice {
display: flex;
align-items: center;
gap: 0.5em;
}
.switch-slice label {
font-weight: bold;
}
.switch-slice .switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch-slice .switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slice .slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.switch-slice .slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
.switch-slice input:checked + .slider {
background-color: #48c774;
}
.switch-slice input:checked + .slider:before {
transform: translateX(26px);
}

View File

View File

@ -4,7 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NenuScanner</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<link rel="stylesheet" href="/static/custom.css">
{% block extracss %}{% endblock extracss %}
</head>
<body>
@ -21,26 +23,31 @@
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/camera">Caméra</a>
<a class="navbar-item" href="/leds">Leds</a>
</div>
<div class="navbar-end">
<a id="calibration-tag-0" class="calibration-tag navbar-item" href="/calibrate/" {% if calibration.state != 0 %}style="display: none;"{% endif %}>
<a class="navbar-item" href="/restart">Redémarrer le serveur</a>
<a id="calibration-tag-0" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 0 %}style="display: none;"{% endif %}>
<span id="calibration-tag-0" class="tags has-addons">
<span class="tag is-dark">étalonnage</span>
<span class="tag is-danger">aucune donnée</span>
</span>
</a>
<a id="calibration-tag-1" class="calibration-tag navbar-item" href="/calibrate/" {% if calibration.state != 1 %}style="display: none;"{% endif %}>
<a id="calibration-tag-1" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 1 %}style="display: none;"{% endif %}>
<span class="tags has-addons" >
<span class="tag is-dark">étalonnage</span>
<span class="tag is-warning">non calculé</span>
</span>
</a>
<a id="calibration-tag-2" class="calibration-tag navbar-item" href="/calibrate/" {% if calibration.state != 2 %}style="display: none;"{% endif %}>
<a id="calibration-tag-2" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 2 %}style="display: none;"{% endif %}>
<span class="tags has-addons">
<span class="tag is-dark">étalonnage</span>
<span class="tag is-warning">non validé</span>
</span>
</a>
<a id="calibration-tag-3" class="calibration-tag navbar-item" href="/calibrate" {% if calibration.state != 3 %}style="display: none;"{% endif %}>
<a id="calibration-tag-3" class="calibration-tag navbar-item" href="/calibration/calibrate" {% if calibration.state != 3 %}style="display: none;"{% endif %}>
<span class="tags has-addons">
<span class="tag is-dark">étalonnage</span>
<span class="tag is-success">validé le {{ calibration.get_pretty_short_date() }}</span>

View File

@ -5,25 +5,20 @@
<div class="container">
<h1 class="title">Étalonnage</h1>
<div class="mb-2">
<p>Placez la mire devant le scanner puis appuyez sur le bouton pour prévisualiser ou étalonner le scanner.</p>
<p>Placez la mire devant le scanner puis appuyez sur le bouton étalonner le scanner.</p>
</div>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<button id="preview-button" class="button is-link">Prévisualiser</button>
<button id="scan-button" class="button is-link">Acquérir les données d'étalonnage</button>
</div>
<div class="control">
<button id="scan-button" class="button is-link">Acquérir les données d'étalonnage</button>
<a href="/calibration/use-last" class="button is-link">Réutiliser le dernier étalonnage</a>
</div>
</div>
<article id="error-container" class="message is-danger" style="display: none;">
<div id="error-content" class="message-body">
</div>
</article>
<div class="columns is-desktop">
<div class="column is-offset-4 is-4">
<img id="preview-image" style="display: none;">
</div>
</div>
<div class="fixed-grid has-8-cols">
<div id="grid" class="grid">
@ -42,36 +37,14 @@
<script>
let scanIndex = 1;
let progress = 0;
let previewButton = document.getElementById('preview-button');
let scanButton = document.getElementById('scan-button');
let progressBar = document.getElementById('progress-bar');
let previewImage = document.getElementById('preview-image');
let grid = document.getElementById('grid');
let buttons = [previewButton, scanButton];
let errorContainer = document.getElementById('error-container');
let errorContent = document.getElementById('error-content');
let calibrateDiv = document.getElementById('calibrate');
let calibrateButton = document.getElementById('calibrate-button');
previewButton.addEventListener('click', async () => {
buttons.forEach(x => x.setAttribute('disabled', 'disabled'));
previewButton.classList.add('is-loading');
let response = await fetch('/api/preview/{{ calibration.id }}');
let uuid = await response.text();
if (response.status >= 200 && response.status < 400) {
previewImage.src = "/data/{{ calibration.id }}/previews/" + uuid + ".jpg";
previewImage.style.display = "block";
errorContainer.style.display = "none";
} else {
errorContainer.style.display = "block";
errorContent.innerHTML = uuid;
}
previewButton.classList.remove('is-loading');
buttons.forEach(x => x.removeAttribute('disabled'));
});
// If we already have calibration images, we show them right now
if ({% if calibration.state > 0 %}true{% else %}false{% endif %}) {
let cell, img;
@ -90,20 +63,19 @@
scanIndex++;
progress = 0;
progressBar.value = 0;
previewImage.style.display = "none";
buttons.forEach(x => x.setAttribute('disabled', 'disabled'));
scanButton.setAttribute('disabled', 'disabled');
scanButton.classList.add('is-loading');
progressBar.style.display = "block";
grid.innerHTML = '';
let response = await fetch('/api/scan-for-calibration');
let response = await fetch('/calibration/scan');
let reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
let { value, done } = await reader.read();
if (done) {
buttons.forEach(x => x.removeAttribute('disabled'));
scanButton.removeAttribute('disabled');
scanButton.classList.remove('is-loading');
calibrateDiv.style.display = "block";
@ -114,22 +86,31 @@
break;
}
let [ uuid, ratio ] = value.trim().split(',');
progress = Math.ceil(1000 * parseFloat(ratio));
let cell = document.createElement('div');
cell.classList.add('cell');
let img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/calibrations/{{ calibration.id }}/' + uuid + '.jpg?v=' + scanIndex;
cell.appendChild(img);
grid.appendChild(cell);
for (let line of value.split('\n')) {
if (line === "") {
continue;
}
let { status, id, ratio } = JSON.parse(line);
progress = Math.ceil(1000 * parseFloat(ratio));
if (status === "ready") {
let cell = document.createElement('div');
cell.classList.add('cell');
let img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/calibrations/{{ calibration.id }}/' + id + '.jpg?v=' + scanIndex;
cell.appendChild(img);
grid.appendChild(cell);
}
}
}
});
calibrateButton.addEventListener('click', async () => {
calibrateButton.classList.add('is-loading');
await fetch('/api/calibrate');
await fetch('/calibration/compute');
window.location.reload();
});

View File

@ -51,14 +51,14 @@
</button>
</div>
<div class="control">
<a href="/new-calibration" class="button is-link">Créer un nouvel étalonnage</a>
<a href="/calibration/create" class="button is-link">Créer un nouvel étalonnage</a>
</div>
{% else %}
<div class="control">
<a href="/cancel-calibration" class="button">Retourner à la page d'acquisition</a>
<a href="/calibration/cancel" class="button">Retourner à la page d'acquisition</a>
</div>
<div class="control">
<a href="/validate-calibration" class="button is-link">Valider l'étalonnage</a>
<a href="/calibration/validate" class="button is-link">Valider l'étalonnage</a>
</div>
{% endif %}
</div>

View File

@ -0,0 +1,170 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Configurer la camera</h1>
<form id="camera-config-form">
<div class="columns">
<div class="column is-half">
<div class="cameraSettings" id="camera-config-container"></div>
</div>
<div class="column is-half has-text-centered">
<figure class="image is-4by3" style="max-width: 480px; margin: auto;">
<img class="preview" id="camera-preview" alt="Camera Preview"
style="border: 1px solid #ccc;">
</figure>
<button class="button is-small is-info mt-2" type="button" onclick="refreshPreview()">Rafraîchir
laperçu
</button>
<button class="button is-small is-info mt-2" type="button" onclick="CapturePreview()">Acquistion camera
</button>
<nav class="level">
<div class="level-item has-text-centered">
<div>
<p class="heading">Speed</p>
<p id="display-camera-shutterspeed" class="title is-5">Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">f/</p>
<p id="display-camera-aperture" class="title is-5"> Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">ISO</p>
<p id="display-camera-iso" class="title is-5">Updating ...</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">°K</p>
<p id="display-camera-color-temperature" class="title is-5">Updating ...</p>
</div>
</div>
</nav>
</div>
</div>
</form>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
function setConfig(paramName) {
const value = document.getElementById(paramName).value;
fetch('/camera/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [paramName]: value })
}).then(() => location.reload());
}
const img = document.getElementById('camera-preview');
img.onload = function() {
requestAnimationFrame(refreshPreview);
}
function refreshPreview() {
const url = '/camera/feed.jpg?' + new Date().getTime();
img.src = url;
}
document.addEventListener('DOMContentLoaded', function () {
fetch('/camera/config')
.then(response => response.json())
.then(data => {
console.log(data);
// data is grouped_params
const container = document.getElementById('camera-config-container');
container.innerHTML = '';
const captureSection = data.find(group => group.section === 'capturesettings');
if (captureSection) {
// Find the shutterspeed param
const shutterParam = captureSection.params.find(param => param.name === 'shutterspeed');
if (shutterParam) {
document.getElementById('display-camera-shutterspeed').textContent = shutterParam.current;
}
// find the aperture param
const apertureParam = captureSection.params.find(param => param.name === 'aperture');
if (apertureParam) {
document.getElementById('display-camera-aperture').textContent = apertureParam.current;
}
}
const imgSection = data.find(group => group.section === 'imgsettings');
if (imgSection) {
// Find the iso param
const isoParam = imgSection.params.find(param => param.name === 'iso');
if (isoParam) {
document.getElementById('display-camera-iso').textContent = isoParam.current;
}
// find the whitebalance param
const wbParam = imgSection.params.find(param => param.name === 'colortemperature');
if (wbParam) {
document.getElementById('display-camera-color-temperature').textContent = wbParam.current;
}
}
data.forEach(group => {
const box = document.createElement('div');
box.className = 'box';
box.innerHTML = `<h2 class="subtitle has-text-weight-bold">${group.section.replace('settings', ' Settings')}</h2>`;
group.params.forEach(param => {
let field = document.createElement('div');
if (param.type == 'TEXT') {
field.className = 'text';
field.innerHTML = `
<p> ${param.name} </p>
`;
}else {
field.className = 'field';
field.innerHTML = `
<label class="label" for="${param.name}">${param.label}:</label>
<div class="control">
<div class="select is-fullwidth">
<select id="${param.name}" name="${param.name}">
${param.choices.map(choice =>
`<option value="${choice.value}" ${choice.label === param.current ? 'selected' : ''}>
${choice.label} (${choice.value})
</option>`
).join('')}
</select>
</div>
</div>
<div class="control mt-2">
<button class="button is-small is-primary" type="button" onclick="setConfig('${param.name}')">Set</button>
</div>
`;
}
box.appendChild(field);
});
container.appendChild(box);
});
// When ready, load preview
refreshPreview();
});
});
function CapturePreview() {
fetch('/camera/capture_preview', {method: 'POST'})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
refreshPreview(); // Rafraîchir l'image après la capture
} else {
alert('Erreur lors de la capture : ' + (data.error || 'inconnue'));
}
});
}
</script>
{% endblock extrajs %}

View File

@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block extracss %}
<style>
.project {
display: none;
}
</style>
{% endblock %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Bienvenue sur NenuScanner</h1>
{% if projects %}
<div class="content">
<p>Voici les projets existants dans la base de données :
<div class="fixed-grid has-6-cols">
<div class="grid">
{% for project in projects %}
<a href="#" id="project-{{ loop.index0 }}" class="cell p-2 has-text-centered has-text-white-dark" style="border: solid; border-width: 1px; border-radius: 5px;">
<div>
<strong>{{ project.name }}</strong>
</div>
<div>
<em>({{ project.objects | length }} objets)</em>
</div>
</a>
{% endfor %}
</div>
</div>
{% for project in projects %}
<div class="project project-{{ loop.index0 }}">
<h2>{{ project.name }} <em>({{ project.objects | length }} objets)</em></h2>
<div class="fixed-grid has-6-cols mb-5">
<div class="grid">
{% for object in project.objects %}
<a href="/object/{{ object.id }}" class="cell p-2 has-text-centered has-text-white-dark" style="border: solid; border-width: 1px; border-radius: 5px;">
<div>
<strong>{{ object.name }}</strong>
</div>
<div>
{% if object.acquisitions %}
<img src="/data/objects/{{ object.id }}/{{ object.acquisitions[0].id }}/{{ leds[0] }}.jpg">
{% else %}
<em>Pas d'acquisitions</em>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p>Il n'y a aucun projet pour le moment...</p>
{% endif %}
<div class="field">
<div class="control">
<button id="add-object" class="button is-link">Ajouter un nouvel objet</button>
</div>
</div>
<div id="add-object-modal" class="modal">
<div class="modal-background"></div>
<form action="/object/create" method="POST">
<div class="modal-content">
<div class="field">
<label class="label has-text-white-dark">Nom du projet</label>
<div class="control">
<input class="input" type="text" name="project" placeholder="Nom du projet" required>
</div>
</div>
<div class="field">
<label class="label has-text-white-dark">Nom de l'objet</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="Nom de l'objet" required>
</div>
</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-link">Créer un objet</button>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</form>
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
document.addEventListener('DOMContentLoaded', () => {
let addObjectButton = document.getElementById('add-object');
let addObjectModal = document.getElementById('add-object-modal');
// Modal button show the modal
addObjectButton.addEventListener('click', () => {
addObjectModal.classList.add('is-active');
});
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach((close) => {
close.addEventListener('click', () => {
addObjectModal.classList.remove('is-active');
});
});
});
let projects = document.getElementsByClassName('project');
for (let i = 0; i < {{ projects | length }}; i++) {
let clickedProject = document.getElementById('project-' + i);
clickedProject.addEventListener('click', () => {
let same = clickedProject.classList.contains('has-text-link');
if (same) {
clickedProject.classList.remove('has-text-link');
clickedProject.classList.add('has-text-white-dark');
} else {
clickedProject.classList.remove('has-text-white-dark');
clickedProject.classList.add('has-text-link');
}
for (let j = 0; j < {{ projects | length }}; j++) {
if (j !== i) {
let otherProject = document.getElementById('project-' + j);
otherProject.classList.remove('has-text-link');
otherProject.classList.add('has-text-white-dark');
}
}
for (let p of projects) {
if (p.classList.contains('project-' + i) && !same) {
p.style.display = "block";
} else {
p.style.display = "none";
}
}
});
}
</script>
{% endblock extrajs %}

View File

@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Contrôler les LEDS</h1>
<div id="leds-container" class="columns is-multiline">
<!-- Les switches seront créés à la volée par JS à partir de la variable `leds` -->
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<style>
/* switch simple */
.switch-slice {
display:flex;
align-items:center;
gap:0.5rem;
}
.switch {
position: relative;
display: inline-block;
width: 46px;
height: 24px;
}
.switch input { display: none; }
.slider {
position:absolute; inset:0; cursor:pointer;
background:#ddd; border-radius:24px; transition:.2s;
}
.slider:before {
content:""; position:absolute; height:18px; width:18px; left:3px; top:3px;
background:#fff; border-radius:50%; transition:.2s;
}
.switch input:checked + .slider { background:#48c774; }
.switch input:checked + .slider:before { transform: translateX(22px); }
/* remove fixed width so Bulma columns control layout */
.led-box { padding:0.75rem; max-width: 100%; }
.led-name { font-weight:600; margin-bottom:.35rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
// leds list injected by Jinja
const leds = {{ leds | tojson | safe }};
const container = document.getElementById('leds-container');
// helper: send state to server
async function setLedState(ledName, state, checkbox) {
try {
const res = await fetch('/leds/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ led: ledName, state: state })
});
const json = await res.json();
if (res.ok && json.status === 'ok') {
checkbox.classList.remove('has-text-danger');
checkbox.classList.add('has-text-success');
setTimeout(()=>{ checkbox.classList.remove('has-text-success'); }, 500);
} else {
console.error('LED set error', json);
alert('Erreur commande LED: ' + (json.error || res.status));
}
} catch (e) {
console.error(e);
alert('Erreur réseau lors de la commande LED');
}
}
// create a switch card for each led name/uuid
leds.forEach(function(ledName, idx) {
// Use Bulma column classes to get max 3 per row on desktop
const col = document.createElement('div');
col.className = 'column is-one-third-desktop is-half-tablet is-full-mobile led-box';
const box = document.createElement('div');
const label = document.createElement('div');
label.className = 'led-name';
label.textContent = ledName;
box.appendChild(label);
const switchRow = document.createElement('div');
switchRow.className = 'switch-slice';
const offLabel = document.createElement('span');
offLabel.textContent = 'OFF';
offLabel.style.fontSize = '0.9rem';
const switchLabel = document.createElement('label');
switchLabel.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = 'led-switch-' + idx;
input.dataset.led = ledName;
const slider = document.createElement('span');
slider.className = 'slider';
switchLabel.appendChild(input);
switchLabel.appendChild(slider);
const onLabel = document.createElement('span');
onLabel.textContent = 'ON';
onLabel.style.fontSize = '0.9rem';
switchRow.appendChild(offLabel);
switchRow.appendChild(switchLabel);
switchRow.appendChild(onLabel);
box.appendChild(switchRow);
// Optional small status text
const status = document.createElement('div');
status.style.marginTop = '0.5rem';
status.style.fontSize = '0.85rem';
status.textContent = 'état: inconnu';
box.appendChild(status);
col.appendChild(box);
container.appendChild(col);
// event
input.addEventListener('change', function (ev) {
const led = ev.target.dataset.led;
const state = ev.target.checked ? 'on' : 'off';
status.textContent = 'état: ' + state;
setLedState(led, state, status);
});
});
});
</script>
{% endblock extrajs %}

View File

@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container content">
<h1 class="title">{{ object.name }}</h1>
<div class="field is-grouped">
<div class="control">
<a class="button is-link" href="/object/download/tar/{{ object.id }}">Télécharger les données de l'objet (archive TAR)</a>
</div>
<div class="control">
<a class="button is-link" href="/object/download/zip/{{ object.id }}">Télécharger les données de l'objet (archive ZIP)</a>
</div>
</div>
{% if object.acquisitions %}
<div class="fixed-grid has-6-cols">
<div class="grid">
{% for acquisition in object.acquisitions %}
<a class="cell" href="/acquisition/rescan/{{ acquisition.id }}">
<div class="has-text-centered p-3" style="border-radius: 15px; border-width: 1px; border-color: {% if acquisition.validated %}green{% else %}red{% endif %}; border-style: solid;">
<div>
<img src="/data/objects/{{ object.id }}/{{ acquisition.id }}/{{ leds[0] }}.jpg">
</div>
<p>
{{ acquisition.get_pretty_date() }}
</p>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="field is-grouped">
{% if calibration.state == CalibrationState.IsValidated %}
<div class="control">
<a href="/acquisition/scan/{{ object.id }}" class="button is-link">Faire un scan</a>
</div>
{% else %}
<div class="control">
<button class="button is-link" id="scan">Faire un scan</button>
</div>
{% endif %}
<div class="control">
<button id="delete-object" class="button is-danger">Supprimer cet objet</button>
</div>
<div class="control">
<button id="upload-object" class="button is-success">Envoyer cet objet vers le serveur pour reconstruction</button>
</div>
</div>
{% if calibration.state != CalibrationState.IsValidated %}
<div id="calibration-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="has-text-centered mb-3">Le scanner n'est pas étalonné.</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<a href="/calibration/calibrate" class="button is-link">Étalonner le scanner</a>
</div>
<div class="control">
<button id="use-last-calibration-button" href="/calibration/use-last" class="button is-link">Réutiliser le dernier étalonnage</button>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
{% endif %}
<div id="delete-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="has-text-centered mb-3">Voulez-vous vraiment supprimer l'objet ?</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button custom-modal-close">Annuler</button>
</div>
<div class="control">
<a href="/object/delete/{{ object.id }}" class="button is-danger">Supprimer cet objet</a>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
document.getElementById('delete-object').addEventListener('click', () => {
let modal = document.getElementById('delete-modal');
modal.classList.add('is-active');
});
{% if calibration.state < CalibrationState.IsValidated %}
document.getElementById('scan').addEventListener('click', () => {
let modal = document.getElementById('calibration-modal');
modal.classList.add('is-active');
});
document.getElementById('use-last-calibration-button').addEventListener('click', async () => {
let resp = await fetch('/api/use-last-calibration');
await resp.text();
window.location.href = '/scan/{{ object.id }}';
});
{% endif %}
(document.querySelectorAll('.modal-background, .modal-close, .custom-modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach((close) => {
close.addEventListener('click', () => {
for (let modal of document.querySelectorAll('#calibration-modal, #delete-modal')) {
modal.classList.remove('is-active');
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-1">Redémarrage en cours</h1>
<p>Merci de patienter</p>
</div>
</div>
{% endblock content %}
{% block extrajs %}
<script>setTimeout(function(){ window.location.href = "/"; }, 2000);</script>
{% endblock extrajs %}

View File

@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Faire une acquisition</h1>
<div class="mb-2">
<p>Placez l'objet devant le scanner puis appuyez sur le bouton pour lancer l'acquisition.</p>
</div>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<button {% if not calibrated or (acquisition and acquisition.validated) %}disabled{% endif %} id="scan-button" class="button is-link" {% if acquisition and acquisition.validated %}title="Vous ne pouvez pas refaire cette acquisition car elle a été validée"{% elif not calibrated %}title="Étalonnez le scanner pour pouvoir lancer l'acquisition"{% endif %}>Lancer l'acquisition</button>
</div>
</div>
<article id="error-container" class="message is-danger" style="display: none;">
<div id="error-content" class="message-body">
</div>
</article>
<div class="fixed-grid has-8-cols">
<div id="grid" class="grid">
</div>
</div>
<progress id="progress-bar" class="progress is-link" style="display: none;" value="0" max="1000"></progress>
<div id="calibrate" {% if not acquisition %}style="display: none;"{% endif %}>
<p>Si les données acquises conviennent, appuyez sur le bouton ci-dessous pour valider l'acquisition.</p>
<div class="field is-grouped">
{% if acquisition %}
{% if acquisition.validated %}
<div class="control">
<button disabled id="calibrate-button" class="button is-link">Cette acquisition a déjà été validée</button>
</div>
{% else %}
<div class="control">
<a href="/acquisition/validate/{{ acquisition.id }}" id="calibrate-button" class="button is-link">Valider l'acquisition</a>
</div>
{% endif %}
{% else %}
<div class="control">
<a id="validate-button" class="button is-link">Valider l'acquisition</a>
</div>
{% endif %}
<div class="control">
<button id="delete-button" class="button is-danger">Supprimer l'acquisition</button>
</div>
</div>
<div id="delete-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="has-text-centered mb-3">Voulez-vous vraiment supprimer l'acquisition ?</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button custom-modal-close">Annuler</button>
</div>
<div class="control">
<a id="delete-link" {% if acquisition %} href="/acquisition/delete/{{ acquisition.id }}"{% endif %} class="button is-danger">Supprimer cette acquisition</a>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
let acquisitionId = {% if acquisition %}{{ acquisition.id }}{% else %}null{% endif %};
let scanIndex = 1;
let progress = 0;
let scanButton = document.getElementById('scan-button');
let progressBar = document.getElementById('progress-bar');
let grid = document.getElementById('grid');
let buttons = [scanButton];
let errorContainer = document.getElementById('error-container');
let errorContent = document.getElementById('error-content');
let calibrateDiv = document.getElementById('calibrate');
let validateButton = document.getElementById('validate-button');
let deleteButton = document.getElementById('delete-button');
let deleteLink = document.getElementById('delete-link');
let modal = document.getElementById('delete-modal');
document.getElementById('delete-button').addEventListener('click', () => {
modal.classList.add('is-active');
});
(document.querySelectorAll('.modal-background, .modal-close, .custom-modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach((close) => {
close.addEventListener('click', () => {
modal.classList.remove('is-active');
});
});
// If we already have calibration images, we show them right now
if (acquisitionId !== null) {
let cell, img;
{% for led in leds %}
cell = document.createElement('div');
cell.classList.add('cell');
img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/{{ led }}.jpg?v=0';
cell.appendChild(img);
grid.appendChild(cell);
{% endfor %}
cell = document.createElement('div');
cell.classList.add('cell');
img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/all_on.jpg?v=0';
cell.appendChild(img);
grid.appendChild(cell);
cell = document.createElement('div');
cell.classList.add('cell');
img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/all_off.jpg?v=0';
cell.appendChild(img);
grid.appendChild(cell);
}
scanButton.addEventListener('click', async () => {
scanIndex++;
progress = 0;
progressBar.value = 0;
buttons.forEach(x => x.setAttribute('disabled', 'disabled'));
scanButton.classList.add('is-loading');
progressBar.style.display = "block";
grid.innerHTML = '';
let response;
if (acquisitionId === null) {
response = await fetch('/acquisition/run/{{ object.id }}');
} else {
response = await fetch('/acquisition/rerun/' + acquisitionId);
}
let reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
let { value, done } = await reader.read();
if (done) {
buttons.forEach(x => x.removeAttribute('disabled'));
scanButton.classList.remove('is-loading');
calibrateDiv.style.display = "block";
break;
}
if (acquisitionId === null) {
acquisitionId = parseInt(value, 10);
validateButton.setAttribute('href', '/acquisition/validate/' + acquisitionId);
deleteLink.setAttribute('href', '/acquisition/delete/' + acquisitionId);
window.history.pushState('', '', '/acquisition/rescan/' + acquisitionId);
continue;
}
for (let line of value.split('\n')) {
if (line === "") {
continue;
}
let { status, id, ratio } = JSON.parse(line);
progress = Math.ceil(1000 * parseFloat(ratio));
if (status === "ready") {
let cell = document.createElement('div');
cell.classList.add('cell');
let img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/' + id + '.jpg?v=' + scanIndex;
cell.appendChild(img);
grid.appendChild(cell);
}
}
}
});
function refreshProgressBar() {
if (progress !== progressBar.value) {
progressBar.value = Math.min(progressBar.value + 5, progress);
}
requestAnimationFrame(refreshProgressBar);
}
refreshProgressBar();
</script>
{% endblock extrajs %}

16
src/nenuscanner/utils.py Normal file
View File

@ -0,0 +1,16 @@
from flask import session
import sqlite3
from . import db
def get_calibration(conn: sqlite3.Connection) -> db.Calibration:
"""
Retrieves the calibration from the session and the database.
Returns empty calibration if nothing is found.
"""
calibration_id = session.get('calibration_id', None)
if calibration_id is None:
return db.Calibration.Dummy
return db.Calibration.get_from_id(calibration_id, conn)

7
stream.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# Strame ok mais stream imge fixe
#gphoto2 --stdout --capture-movie | ffmpeg -i - -c:v libx264 -f hls -hls_time 2 -hls_list_size 3 -hls_flags delete_segments src/nenuscanner/static/stream.m3u8
gphoto2 --capture-image-and-download -F 0 -I 2 --stdout | ffmpeg -f image2pipe -framerate 0.5 -i - -r 25 -c:v libx264 -f hls -hls_time 2 -hls_list_size 3 -hls_flags delete_segments -g 50 -keyint_min 50 src/nenuscanner/static/stream.m3u8

View File

@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Bienvenue sur NenuScanner</h1>
{% if objects %}
<div class="content">
<p>Voici les objets existants dans la base de données :
<ul>
{% for object in objects %}
<li><a href="/object/{{ object.id }}">{{ object.name }}</a></li>
{% endfor %}
</ul>
</div>
{% else %}
<p>Il n'y a aucun objet pour le moment...</p>
{% endif %}
<button id="add-object" class="button is-link">Ajouter un nouvel objet</button>
<div id="add-object-modal" class="modal">
<div class="modal-background"></div>
<form action="/create-object/" method="POST">
<div class="modal-content">
<div class="field">
<label class="label">Nom de l'objet</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="Nom de l'objet" required>
</div>
</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-link">Créer un objet</button>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</form>
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
document.addEventListener('DOMContentLoaded', () => {
let addObjectButton = document.getElementById('add-object');
let addObjectModal = document.getElementById('add-object-modal');
// Modal button show the modal
addObjectButton.addEventListener('click', () => {
addObjectModal.classList.add('is-active');
});
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach((close) => {
close.addEventListener('click', () => {
addObjectModal.classList.remove('is-active');
});
});
});
</script>
{% endblock extrajs %}

View File

@ -1,73 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container content">
<h1 class="title">{{ object.name }}</h1>
<div class="my-3">
<a class="button is-link mr-3" href="/download-object/tar/{{ object.id }}">Télécharger les données de l'objet (archive TAR)</a>
<a class="button is-link" href="/download-object/zip/{{ object.id }}">Télécharger les données de l'objet (archive ZIP)</a>
</div>
{% if object.acquisitions %}
<div class="fixed-grid has-6-cols">
<div class="grid">
{% for acquisition in object.acquisitions %}
<a class="cell" href="/scan-acquisition/{{ acquisition.id }}">
<div class="has-text-centered p-3" style="border-radius: 15px; border-width: 1px; border-color: {% if acquisition.validated %}green{% else %}red{% endif %}; border-style: solid;">
<div>
<img src="/data/objects/{{ object.id }}/{{ acquisition.id }}/{{ leds[0] }}.jpg">
</div>
<p>
{{ acquisition.get_pretty_date() }}
</p>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if calibration.state == CalibrationState.IsValidated %}
<a href="/scan/{{ object.id }}" class="button is-link">Faire un scan</a>
{% else %}
<button class="button is-link" id="scan">Faire un scan</button>
<div id="calibration-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="has-text-centered mb-3">Le scanner n'est pas étalonné.</div>
<div class="field is-grouped is-grouped-centered">
<div class="control">
<a href="/calibrate/" class="button is-link">Étalonner le scanner</a>
</div>
<div class="control">
<button id="use-last-calibration-button" href="/use-last-calibration/" class="button is-link">Réutiliser le dernier étalonnage</button>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</form>
{% endif %}
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}{% if calibration.state < CalibrationState.IsValidated %}
<script>
let modal = document.getElementById('calibration-modal');
document.getElementById('scan').addEventListener('click', () => {
modal.classList.add('is-active');
});
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach((close) => {
close.addEventListener('click', () => {
modal.classList.remove('is-active');
});
});
document.getElementById('use-last-calibration-button').addEventListener('click', async () => {
let resp = await fetch('/api/use-last-calibration');
await resp.text();
window.location.href = '/scan/{{ object.id }}';
});
</script>
{% endif %}{% endblock %}

View File

@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">Faire une acquisition</h1>
<div class="mb-2">
<p>Placez l'objet devant le scanner puis appuyez sur le bouton pour lancer l'acquisition.</p>
</div>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<button {% if acquisition and acquisition.validated %}disabled{% endif %} id="scan-button" class="button is-link">Lancer l'acquisition</button>
</div>
</div>
<article id="error-container" class="message is-danger" style="display: none;">
<div id="error-content" class="message-body">
</div>
</article>
<div class="fixed-grid has-8-cols">
<div id="grid" class="grid">
</div>
</div>
<progress id="progress-bar" class="progress is-link" style="display: none;" value="0" max="1000"></progress>
<div id="calibrate" {% if not acquisition %}style="display: none;"{% endif %}>
<p>Si les données acquises conviennent, appuyez sur le bouton ci-dessous pour valider l'acquisition.</p>
{% if acquisition %}
{% if acquisition.validated %}
<button disabled id="calibrate-button" class="button is-link">Cette acquisition a déjà été validée</button>
{% else %}
<a href="/validate-acquisition/{{ acquisition.id }}" id="calibrate-button" class="button is-link">Valider l'acquisition</a>
{% endif %}
{% else %}
<a id="calibrate-button" class="button is-link">Valider l'acquisition</a>
{% endif %}
</div>
</div>
</section>
{% endblock content %}
{% block extrajs %}
<script>
let acquisitionId = {% if acquisition %}{{ acquisition.id }}{% else %}null{% endif %};
let scanIndex = 1;
let progress = 0;
let scanButton = document.getElementById('scan-button');
let progressBar = document.getElementById('progress-bar');
let grid = document.getElementById('grid');
let buttons = [scanButton];
let errorContainer = document.getElementById('error-container');
let errorContent = document.getElementById('error-content');
let calibrateDiv = document.getElementById('calibrate');
let calibrateButton = document.getElementById('calibrate-button');
// If we already have calibration images, we show them right now
if (acquisitionId !== null) {
let cell, img;
{% for led in leds %}
cell = document.createElement('div');
cell.classList.add('cell');
img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/{{ led }}.jpg?v=0';
cell.appendChild(img);
grid.appendChild(cell);
{% endfor %}
}
scanButton.addEventListener('click', async () => {
scanIndex++;
progress = 0;
progressBar.value = 0;
buttons.forEach(x => x.setAttribute('disabled', 'disabled'));
scanButton.classList.add('is-loading');
progressBar.style.display = "block";
grid.innerHTML = '';
let response;
if (acquisitionId === null) {
response = await fetch('/api/scan-for-object/{{ object.id }}');
} else {
response = await fetch('/api/scan-for-acquisition/' + acquisitionId);
}
let reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
let { value, done } = await reader.read();
if (done) {
buttons.forEach(x => x.removeAttribute('disabled'));
scanButton.classList.remove('is-loading');
calibrateDiv.style.display = "block";
break;
}
if (acquisitionId === null) {
acquisitionId = parseInt(value, 10);
calibrateButton.setAttribute('href', '/validate-acquisition/' + acquisitionId);
continue;
}
let [ uuid, ratio ] = value.trim().split(',');
progress = Math.ceil(1000 * parseFloat(ratio));
let cell = document.createElement('div');
cell.classList.add('cell');
let img = document.createElement('img');
img.classList.add('is-loading');
img.src = '/data/objects/{{ object.id }}/' + acquisitionId + '/' + uuid + '.jpg?v=' + scanIndex;
cell.appendChild(img);
grid.appendChild(cell);
}
});
function refreshProgressBar() {
if (progress !== progressBar.value) {
progressBar.value = Math.min(progressBar.value + 5, progress);
}
requestAnimationFrame(refreshProgressBar);
}
refreshProgressBar();
</script>
{% endblock extrajs %}

View File

@ -158,6 +158,7 @@ export class Engine {
initScene(calibration: Calibration): void {
this.center = new THREE.Vector3(0, 0, 10);
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0, 0, 0);
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.001, 1000);
this.camera.position.set(0, 0, -30);