Compare commits
16 Commits
a512e710fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de88f4a856 | ||
|
|
9c9eb81437 | ||
|
|
9d6254b478 | ||
|
|
9e8cc5d575 | ||
|
|
ea5b3c6860 | ||
|
|
36efc0da0a | ||
|
|
cddd5317c3 | ||
|
|
b1a7baae19 | ||
|
|
c993f74511 | ||
|
|
39551f3794 | ||
|
|
bfaa0eca2f | ||
|
|
373b17d8a5 | ||
|
|
ca742aa204 | ||
|
|
831322e44a | ||
|
|
24b65a8ee5 | ||
|
|
005292adfc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
**/*.png
|
||||
target
|
||||
.env
|
||||
MTAM1.3.0-20200525WAI-H*
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
904
Cargo.lock
generated
904
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -5,22 +5,27 @@ edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "thermaldecoder"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.77"
|
||||
axum = "0.7.4"
|
||||
bracket-color = "0.8.7"
|
||||
clap = { version = "4.5.1", features = ["derive"] }
|
||||
crossbeam = "0.8.4"
|
||||
crossbeam-channel = "0.5.11"
|
||||
crossterm = { version = "0.27.0", features = ["event-stream"] }
|
||||
dotenv = "0.15.0"
|
||||
eframe = "0.26.2"
|
||||
egui = "0.26.2"
|
||||
futures = "0.3.30"
|
||||
futures-timer = "3.0.3"
|
||||
indicatif = "0.17.7"
|
||||
pcap = { version = "1.2.0", features = ["capture-stream"] }
|
||||
pcap-parser = { version = "0.14.1", features = ["data"] }
|
||||
png = "0.17.10"
|
||||
pyo3 = { version = "0.20.0", "features" = ["extension-module"] }
|
||||
reqwest = { version = "0.11.24", features = ["json"] }
|
||||
serde = { version = "1.0.193", features = ["derive", "serde_derive", "alloc"] }
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
tracing-subscriber = "0.3.18"
|
||||
tui-textarea = "0.4.0"
|
||||
v4l = { version = "0.14.0", features = ["v4l2"], default-features = false }
|
||||
|
||||
BIN
bootLoginPlay.pcapng
Normal file
BIN
bootLoginPlay.pcapng
Normal file
Binary file not shown.
111
examples/cutoff.rs
Normal file
111
examples/cutoff.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::{collections::HashMap, io::stdout, time::Duration};
|
||||
|
||||
use futures::{future::FutureExt, select, StreamExt};
|
||||
use futures_timer::Delay;
|
||||
|
||||
use crossterm::{
|
||||
cursor::position,
|
||||
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
|
||||
async fn set_cutoff(cutoff: f64, freq: f64) -> Result<(), reqwest::Error> {
|
||||
// Some simple CLI args requirements...
|
||||
let url = format!("http://localhost:3000/cutoff");
|
||||
let mut map = HashMap::new();
|
||||
map.insert("min_cutoff", cutoff);
|
||||
map.insert("max_cutoff", cutoff + 10.0);
|
||||
map.insert("freq_hz", freq);
|
||||
let client = reqwest::Client::new();
|
||||
let res = client.post(url).json(&map).send().await?;
|
||||
|
||||
// eprintln!("Response: {:?} {}", res.version(), res.status());
|
||||
// eprintln!("Headers: {:#?}\n", res.headers());
|
||||
|
||||
// let body = res.text().await?;
|
||||
// println!("{body}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const HELP: &str = r#"EventStream based on futures_util::Stream with tokio
|
||||
- Keyboard, mouse and terminal resize events enabled
|
||||
- Prints "." every second if there's no event
|
||||
- Hit "c" to print current cursor position
|
||||
- Use Esc to quit
|
||||
"#;
|
||||
|
||||
async fn print_events() {
|
||||
let mut reader = EventStream::new();
|
||||
let mut cutoff = 30.0;
|
||||
let mut last_cutoff = cutoff;
|
||||
let mut freq = 1.0;
|
||||
|
||||
loop {
|
||||
let mut delay = Delay::new(Duration::from_millis(1_000)).fuse();
|
||||
let mut event = reader.next().fuse();
|
||||
let mut change = false;
|
||||
select! {
|
||||
_ = delay => {
|
||||
},
|
||||
maybe_event = event => {
|
||||
match maybe_event {
|
||||
Some(Ok(event)) => {
|
||||
if event == Event::Key(KeyCode::Char('c').into()) {
|
||||
println!("Cursor position: {:?}\r", position());
|
||||
}
|
||||
|
||||
if event == Event::Key(KeyCode::Esc.into()) {
|
||||
break;
|
||||
}
|
||||
if let Event::Key(k) = event {
|
||||
if let KeyCode::Char(c) = k.code {
|
||||
change = true;
|
||||
match c {
|
||||
'[' => {
|
||||
cutoff -= 1.0;
|
||||
}
|
||||
']' => {
|
||||
cutoff += 1.0;
|
||||
}
|
||||
'1' => {
|
||||
freq *= 0.9;
|
||||
}
|
||||
'2' => {
|
||||
freq *= 1.1;
|
||||
}
|
||||
_ => {
|
||||
change = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if change {
|
||||
set_cutoff(cutoff, freq).await.unwrap();
|
||||
println!("cutoff = {}\r", cutoff);
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => println!("Error: {:?}\r", e),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
println!("{}", HELP);
|
||||
|
||||
enable_raw_mode()?;
|
||||
|
||||
let mut stdout = stdout();
|
||||
execute!(stdout, EnableMouseCapture)?;
|
||||
|
||||
print_events().await;
|
||||
|
||||
execute!(stdout, DisableMouseCapture)?;
|
||||
|
||||
disable_raw_mode()
|
||||
}
|
||||
49
gst.py
Normal file
49
gst.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import gi
|
||||
gi.require_version('Gst', '1.0')
|
||||
from gi.repository import Gst, GLib
|
||||
|
||||
# Initialize GStreamer
|
||||
Gst.init(None)
|
||||
|
||||
# Define the pipeline
|
||||
pipeline = Gst.parse_launch(
|
||||
"v4l2src device=/dev/video0 ! "
|
||||
"video/x-raw, format=GRAY16_LE, width=358, height=288, framerate=30/1 ! "
|
||||
"videoconvert ! "
|
||||
"x264enc bitrate=500 speed-preset=ultrafast tune=zerolatency ! "
|
||||
"tee name=t "
|
||||
"t. ! queue ! "
|
||||
"hlsdemux ! "
|
||||
"hlssink playlist-root=http://example.com/live location=/var/www/html/live/segment%05d.ts playlist-location=/var/www/html/live/playlist.m3u8 "
|
||||
"t. ! queue ! "
|
||||
"filesink location=/path/to/save/output.h264"
|
||||
)
|
||||
|
||||
# Start playing
|
||||
pipeline.set_state(Gst.State.PLAYING)
|
||||
|
||||
# Wait until error or EOS
|
||||
bus = pipeline.get_bus()
|
||||
|
||||
# Error handling
|
||||
def on_message(bus, message, loop):
|
||||
mtype = message.type
|
||||
if mtype == Gst.MessageType.ERROR:
|
||||
err, debug = message.parse_error()
|
||||
print("Error: %s" % err, debug)
|
||||
loop.quit()
|
||||
elif mtype == Gst.MessageType.EOS:
|
||||
print("End of stream")
|
||||
loop.quit()
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
bus.add_signal_watch()
|
||||
bus.connect("message", on_message, loop)
|
||||
|
||||
try:
|
||||
loop.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
# Free resources
|
||||
pipeline.set_state(Gst.State.NULL)
|
||||
BIN
indesk.pcapng
Normal file
BIN
indesk.pcapng
Normal file
Binary file not shown.
10
pyproject.toml
Normal file
10
pyproject.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "thermalcam"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"scapy>=2.6.1",
|
||||
]
|
||||
@@ -5,8 +5,8 @@ set -e
|
||||
|
||||
# Python works but stutters
|
||||
#sudo ./venv/bin/python ./decode.py --live
|
||||
cargo build --release --example live
|
||||
TARGET=./target/release/examples/live
|
||||
cargo build --release
|
||||
TARGET=./target/release/thermaldecoder
|
||||
# setcap does not work yet (EPERM on socket AF_PACKET)
|
||||
# sudo setcap cap_net_raw,cap_net_admin=eip $TARGET
|
||||
#sudo strace -f -o live.strace $TARGET /dev/video0
|
||||
|
||||
71
src/main.rs
Normal file
71
src/main.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
pub(crate) mod offline;
|
||||
pub(crate) mod stream;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use stream::{initialize, start_stream_thread, Streamer};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
streamer: Arc<Mutex<Streamer>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
println!("creating streamer");
|
||||
let streamer = initialize();
|
||||
println!("creating streamer thread");
|
||||
start_stream_thread(streamer.clone());
|
||||
let state = AppState { streamer };
|
||||
|
||||
println!("starting web interface");
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
// `GET /` goes to `root`
|
||||
.route("/", get(root))
|
||||
// `POST /users` goes to `set_cutoff`
|
||||
.route("/cutoff", post(set_cutoff))
|
||||
.with_state(state);
|
||||
|
||||
// run our app with hyper, listening globally on port 3000
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
// basic handler that responds with a static string
|
||||
async fn root() -> &'static str {
|
||||
"Hello, TAMI!"
|
||||
}
|
||||
|
||||
async fn set_cutoff(
|
||||
State(state): State<AppState>,
|
||||
// this argument tells axum to parse the request body
|
||||
// as JSON into a `CreateUser` type
|
||||
Json(payload): Json<SetCutoff>,
|
||||
) -> (StatusCode, Json<bool>) {
|
||||
let mut streamer = state.streamer.lock().unwrap();
|
||||
streamer.min_cutoff = payload.min;
|
||||
streamer.max_cutoff = payload.max;
|
||||
streamer.freq_hz = payload.hz;
|
||||
println!("updated to {:?}", payload);
|
||||
(StatusCode::OK, true.into())
|
||||
}
|
||||
|
||||
// the input to our `create_user` handler
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct SetCutoff {
|
||||
min: f64,
|
||||
max: f64,
|
||||
hz: f64,
|
||||
}
|
||||
@@ -184,6 +184,7 @@ impl Iterator for Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn write_raw_frame(name: &str, data: &[u8]) -> anyhow::Result<()> {
|
||||
let path = Path::new(&name);
|
||||
let file = File::create(path)?;
|
||||
@@ -196,6 +197,7 @@ pub fn write_raw_frame(name: &str, data: &[u8]) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_calibrated_frame(name: &str, data: &[u16]) -> anyhow::Result<()> {
|
||||
let path = Path::new(&name);
|
||||
let file = File::create(path).unwrap();
|
||||
@@ -252,6 +254,7 @@ fn decode(filename: &str) -> PyResult<PyFrameIterator> {
|
||||
}
|
||||
|
||||
/// writes to frames/<basename of filename>
|
||||
#[allow(dead_code)]
|
||||
pub fn decode_to_files(filename: &str) -> anyhow::Result<()> {
|
||||
let frameiter = Decoder::new(filename)?;
|
||||
let basename = std::path::Path::new(filename)
|
||||
@@ -1,7 +1,13 @@
|
||||
use crate::offline::{Header, HDR_SIZE};
|
||||
use bracket_color::prelude::*;
|
||||
use clap::Parser;
|
||||
use dotenv::dotenv;
|
||||
use std::io::Write;
|
||||
use thermaldecoder::{Header, HDR_SIZE};
|
||||
use std::time::SystemTime;
|
||||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
thread::spawn,
|
||||
};
|
||||
use v4l::video::Output;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -26,9 +32,44 @@ fn pixel_to_celcius(x: u16) -> u16 {
|
||||
(t * 256.0) as u16
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
/// https://en.wikipedia.org/wiki/HSL_and_HSV
|
||||
/// convert to the expected dynamic range first. We insert values in [0..256)
|
||||
/// h in [0, 360] degrees
|
||||
/// s in [0, 1]
|
||||
/// v in [0, 1]
|
||||
fn once_upon_a_time_hsv2rgb(h: u8, s: u8, v: u8) -> (u8, u8, u8) {
|
||||
let h = (h as f64) / 256.0 * 360.0;
|
||||
let s = (s as f64) / 256.0;
|
||||
let v = (v as f64) / 256.0;
|
||||
(0, 0, 0)
|
||||
}
|
||||
|
||||
fn rgb_to_u8s(rgb: &RGB) -> (u8, u8, u8) {
|
||||
(
|
||||
(rgb.r * 256.) as u8,
|
||||
(rgb.g * 256.) as u8,
|
||||
(rgb.b * 256.) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) struct Streamer {
|
||||
pub(crate) min_cutoff: f64,
|
||||
pub(crate) max_cutoff: f64,
|
||||
pub(crate) freq_hz: f64,
|
||||
}
|
||||
|
||||
pub(crate) fn initialize() -> Arc<Mutex<Streamer>> {
|
||||
let args = Args::parse();
|
||||
Arc::new(Mutex::new(Streamer {
|
||||
min_cutoff: args.red_cutoff.unwrap_or(26.),
|
||||
max_cutoff: args.red_cutoff.unwrap_or(26.) + 10.0,
|
||||
freq_hz: 1.0,
|
||||
}))
|
||||
}
|
||||
|
||||
fn main(streamer: Arc<Mutex<Streamer>>) -> anyhow::Result<()> {
|
||||
dotenv().ok();
|
||||
let args = Args::parse();
|
||||
let device = match std::env::var("THERMALCAM_IFACE=enp1s0f0") {
|
||||
Ok(d) => {
|
||||
let device = pcap::Device::list()
|
||||
@@ -50,7 +91,12 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
const WIDTH: usize = 288;
|
||||
const HEIGHT: usize = 384;
|
||||
let greyscale = !args.temperature || args.red_cutoff.is_none();
|
||||
println!("reading cutoff");
|
||||
let start = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
let greyscale = !args.temperature;
|
||||
let fourcc_repr = if greyscale {
|
||||
[
|
||||
b'Y', // | 0b10000000
|
||||
@@ -62,6 +108,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// RGB32 is 4 bytes R, G, B, A
|
||||
[b'R', b'G', b'B', b'4']
|
||||
};
|
||||
println!("using four cc {:?}", fourcc_repr);
|
||||
let bytes_per_pixel = if greyscale { 2 } else { 4 };
|
||||
let fourcc = v4l::format::FourCC { repr: fourcc_repr };
|
||||
let mut out = v4l::Device::with_path(output)?;
|
||||
@@ -103,6 +150,17 @@ fn main() -> anyhow::Result<()> {
|
||||
|| (data.len() + len > FRAME_LEN)
|
||||
{
|
||||
if len == FRAME_LEN {
|
||||
// read once per frame, can make it lower if need be
|
||||
let state = streamer.lock().unwrap();
|
||||
let mid = (state.min_cutoff + state.max_cutoff) / 2.0;
|
||||
let range = state.max_cutoff - state.min_cutoff;
|
||||
let hz = state.freq_hz;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64();
|
||||
let dt = now - start;
|
||||
let cutoff = mid + f64::sin(dt * hz) * 0.5 * range;
|
||||
// swap the bytes, we are using LE, not BE, 16 bit grayscale
|
||||
// possibly limitation of current v4l2loopback or v4l rust wrapper or libv4l2
|
||||
for i in 0..FRAME_LEN / 2 {
|
||||
@@ -118,14 +176,16 @@ fn main() -> anyhow::Result<()> {
|
||||
swapped[out_i..out_i + 2].copy_from_slice(&pixel_swapped);
|
||||
} else {
|
||||
pixel = pixel_to_celcius(pixel);
|
||||
let cutoff = args.red_cutoff.unwrap();
|
||||
let r = if pixel > (256.0 * cutoff) as u16 {
|
||||
255
|
||||
let (r, g, b) = if pixel > (256.0 * cutoff) as u16 {
|
||||
let p = pixel - (256.0 * cutoff) as u16;
|
||||
let rgb = HSV::from_f32(0.0, (p as f32) / 256.0, 0.0).to_rgb();
|
||||
rgb_to_u8s(&rgb)
|
||||
} else {
|
||||
0
|
||||
let rgb =
|
||||
HSV::from_f32(pixel as f32 / 65536.0, 0.0, pixel as f32 / 65536.0)
|
||||
.to_rgb();
|
||||
rgb_to_u8s(&rgb)
|
||||
};
|
||||
let g = frame[i * 2];
|
||||
let b = frame[i * 2 + 1];
|
||||
let out_i = ((HEIGHT - 1 - y) + (WIDTH - 1 - x) * HEIGHT) * 4;
|
||||
swapped[out_i..out_i + 4].copy_from_slice(&[0, r, g, b]);
|
||||
}
|
||||
@@ -139,3 +199,11 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_stream_thread(streamer: Arc<Mutex<Streamer>>) {
|
||||
spawn(move || {
|
||||
if let Err(e) = main(streamer) {
|
||||
println!("oops: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
2
start.sh
2
start.sh
@@ -9,4 +9,4 @@ if [ $mtu -lt 9000 ]; then
|
||||
echo "setting mtu to 9000"
|
||||
sudo ip link set $IFACE mtu 9000
|
||||
fi
|
||||
sudo venv/bin/python ./replay.py
|
||||
sudo uv run ./replay.py
|
||||
|
||||
36
uv.lock
generated
Normal file
36
uv.lock
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scapy"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/2f/035d3888f26d999e9680af8c7ddb7ce4ea0fd8d0e01c000de634c22dcf13/scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5", size = 2247754, upload-time = "2024-11-05T08:43:23.488Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thermalcam"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "scapy" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "scapy", specifier = ">=2.6.1" },
|
||||
]
|
||||
Reference in New Issue
Block a user