"""Batch convert photos to JXL, keep EXIF/ICC and file times, resumable.
- JPEG: long side -> 2400 px (LANCZOS), lossy (--distance/--effort); --in-process skips the cjxl process
- PNG/GIF/BMP/TIFF/PNM: full size, lossless; every result is decoded and compared pixel by pixel,
the original is copied instead if that check fails or JXL is not smaller
- duplicates (copies, re-saves, smaller versions; see dedupe.py) become .lnk shortcuts to one master
- other files: copied as-is up to COPY_MAX_MB
- video files: copied up to copy_video_size, larger ones recoded with ffmpeg (vcompress_cmd)
usage: python -I scripts/convert.py [--distance 2.6] [--effort 3] [--lossless-effort 7] [--workers 16]
[--limit N] [--copy-max-mb MB] [--in-process]
[--no-dedupe] [--dry-run]
"""
import argparse, ctypes, io, os, shutil, stat, subprocess, sys, time, traceback
from ctypes import wintypes
from collections import Counter
from multiprocessing import Pool
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageOps
sys.path.insert(0, str(Path(__file__).parent)) # python -I leaves the script folder off sys.path
import dedupe
SRC = Path(r"C:/Storage/Pictures")
DST = Path(r"C:/Backups/pic_jxld3e5")
COPY_MAX_MB = 1 # copy non-image files up to this size as-is; None = don't copy
LOSSY_EXTS = {".jpg", ".jpeg"}
LOSSLESS_EXTS = {".png", ".apng", ".gif", ".bmp", ".tif", ".tiff", ".ppm", ".pgm", ".pnm"}
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".mov"}
CJXL_READS = {".png", ".apng", ".gif", ".ppm", ".pgm", ".pnm"} # others go through Pillow -> PNG
PIL_SAFE_MODES = {"1", "L", "LA", "P", "PA", "RGB", "RGBA", "I;16"} # modes PNG stores without loss
LONG = 2400
ORIENTATION = 0x0112
# never converted, copied or reported (compared lower-case)
EXCLUDE_NAMES = {"picasa.ini", ".picasa.ini", "thumbs.db", "ehthumbs.db", "ehthumbs_vista.db",
"desktop.ini", ".ds_store", "iconcache.db"}
EXCLUDE_DIRS = {"$recycle.bin", "system volume information", ".picasaoriginals"}
always_copy = {"Sheet Music", "Wohnung Oberhaunstadt", "scans"}
copy_video_size = 50*1024*1024
vcompress_cmd = " -vf scale=1024:-1 -pix_fmt yuv420p -b:v 1024k -b:a 96k -profile:v main12 -movflags +faststart -vcodec libx265 "
def excluded(p):
if p.name.lower() in EXCLUDE_NAMES or any(d.lower() in EXCLUDE_DIRS for d in p.relative_to(SRC).parts[:-1]):
return True
return bool(p.stat().st_file_attributes & stat.FILE_ATTRIBUTE_SYSTEM)
def is_always_copy(p):
parts = p.relative_to(SRC).parts[:-1]
return any(d in always_copy or d.lower() in {x.lower() for x in always_copy} for d in parts)
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_k32.CreateFileW.restype = wintypes.HANDLE
_k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
_k32.SetFileTime.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(ctypes.c_ulonglong)] * 3
def copy_times(src, dst):
"""Give dst the created/accessed/modified times of src (os.utime can't set 'created' on Windows)."""
st = src.stat()
ft = [ctypes.c_ulonglong(ns // 100 + 116444736000000000) # ns since 1970 -> 100 ns since 1601
for ns in (st.st_ctime_ns, st.st_atime_ns, st.st_mtime_ns)] # st_ctime is 'created' on Windows
h = _k32.CreateFileW(str(dst), 0x100, 0, None, 3, 0x80, None) # FILE_WRITE_ATTRIBUTES, OPEN_EXISTING
if h == wintypes.HANDLE(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
try:
if not _k32.SetFileTime(h, *(ctypes.byref(t) for t in ft)):
raise ctypes.WinError(ctypes.get_last_error())
finally:
_k32.CloseHandle(h)
def copy_file(src, dst):
dst.parent.mkdir(parents=True, exist_ok=True)
part = dst.with_name(dst.name + ".part")
shutil.copyfile(src, part)
os.replace(part, dst)
copy_times(src, dst)
def compress_video(src, dst, cmd_str):
dst.parent.mkdir(parents=True, exist_ok=True)
part = dst.with_name(dst.name + ".part")
cmd = ["ffmpeg", "-y", "-i", str(src)] + cmd_str.strip().split() + [str(part)]
r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0:
part.unlink(missing_ok=True)
raise RuntimeError(r.stderr.decode(errors="replace").strip()[:300])
os.replace(part, dst)
copy_times(src, dst)
def prep(p):
im = Image.open(p)
exif = im.getexif()
icc = im.info.get("icc_profile")
w0, h0 = im.size
s = min(1.0, LONG / max(w0, h0)) # never upscale
tw, th = round(w0 * s), round(h0 * s)
if im.format == "JPEG":
im.draft("RGB", (tw, th)) # DCT-domain downscale (>= target), LANCZOS finishes
im = ImageOps.exif_transpose(im.convert("RGB"))
target = (tw, th) if (im.size[0] >= im.size[1]) == (tw >= th) else (th, tw) # transpose may swap axes
if im.size != target:
im = im.resize(target, Image.LANCZOS)
exif.pop(ORIENTATION, None) # pixels are already upright
return im, exif.tobytes() if len(exif) else None, icc
def quality_for_distance(d):
"""Inverse of libjxl's JxlEncoderDistanceFromQuality (the plugin only takes a quality)."""
if d <= 0:
return 100.0
if d <= 6.4: # quality 30..100 maps linearly to distance 6.4..0.1
return 100 - (d - 0.1) / 0.09
a, b, c = 53 / 3000, -23 / 20, 25 - d # below quality 30: d = a q^2 + b q + 25
return (-b - (b * b - 4 * a * c) ** 0.5) / (2 * a)
def encode_inproc(im, exif, opt):
from pillow_jxl import Encoder # only needed with --in-process
if exif:
tiff = exif[6:] if exif.startswith(b"Exif\x00\x00") else exif
exif = b"\0\0\0\0" + tiff # JXL Exif box starts with a 4-byte TIFF header offset; the plugin omits it
enc = Encoder(mode="RGB", lossless=False, quality=quality_for_distance(float(opt["distance"])),
decoding_speed=0, effort=int(opt["effort"]), use_container=True,
use_original_profile=False, num_threads=1)
return enc(im.tobytes(), im.width, im.height, jpeg_encode=False, exif=exif, jumb=None, xmp=None,
compress=True)
def lossy(src, dst, keep, opt):
im, exif, icc = prep(src)
dst.parent.mkdir(parents=True, exist_ok=True)
part = dst.with_name(dst.name + ".part")
if opt["in_process"] and not icc: # the plugin can't store an ICC profile -> those go through cjxl
part.write_bytes(encode_inproc(im, exif, opt))
else:
b = io.BytesIO()
kw = {k: v for k, v in (("exif", exif), ("icc_profile", icc)) if v}
im.save(b, "PNG", compress_level=0, **kw) # PNG so cjxl picks up EXIF/ICC
r = subprocess.run(["cjxl", "-", str(part), "-d", opt["distance"], "-e", opt["effort"], "--quiet",
"--num_threads=1"], input=b.getvalue(), capture_output=True)
if r.returncode:
part.unlink(missing_ok=True)
return src, dst, None, "fail", r.stderr.decode(errors="replace").strip()[:300]
os.replace(part, dst)
copy_times(src, dst)
return src, dst, dst.stat().st_size, "ok", ""
def as_rgba(a):
"""Bring a decoded array to H x W x 4 (gray -> 3 channels, missing alpha -> opaque) for comparison."""
if a.ndim == 2:
a = a[:, :, None]
if a.shape[2] in (1, 2):
a = np.concatenate([np.repeat(a[:, :, :1], 3, 2), a[:, :, 1:]], 2)
if a.shape[2] == 3:
a = np.concatenate([a, np.full(a.shape[:2] + (1,), np.iinfo(a.dtype).max, a.dtype)], 2)
return a
def same_pixels(src, decoded, animated):
if animated: # GIF/APNG: Pillow composites every frame; 8 bit only, which is all these formats hold
def frames(f):
im = Image.open(f)
out = []
for i in range(getattr(im, "n_frames", 1)):
im.seek(i)
out.append(np.asarray(im.convert("RGBA")))
return out
a, b = frames(src), frames(io.BytesIO(decoded))
return len(a) == len(b) and all(x.shape == y.shape and (x == y).all() for x, y in zip(a, b))
# still images: OpenCV keeps 16 bit and does not share code with the Pillow conversion path
a = cv2.imdecode(np.fromfile(src, np.uint8), cv2.IMREAD_UNCHANGED)
b = cv2.imdecode(np.frombuffer(decoded, np.uint8), cv2.IMREAD_UNCHANGED)
if a is None or b is None or a.dtype != b.dtype:
return False
a, b = as_rgba(a), as_rgba(b)
return a.shape == b.shape and bool((a == b).all())
def lossless(src, dst, keep, opt):
def keep_original(why):
copy_file(src, keep)
return src, keep, keep.stat().st_size, "keep", why
ext = src.suffix.lower()
try:
im = Image.open(src)
n_frames = getattr(im, "n_frames", 1)
if ext in (".tif", ".tiff") and n_frames > 1:
return keep_original(f"multi-page TIFF ({n_frames} pages)")
if ext in CJXL_READS:
inp, data = str(src), None
else:
if im.mode not in PIL_SAFE_MODES:
return keep_original(f"image mode {im.mode}")
b = io.BytesIO()
kw = {k: v for k, v in (("icc_profile", im.info.get("icc_profile")),
("exif", im.getexif().tobytes() if len(im.getexif()) else None)) if v}
im.save(b, "PNG", compress_level=0, **kw)
inp, data = "-", b.getvalue()
dst.parent.mkdir(parents=True, exist_ok=True)
part = dst.with_name(dst.name + ".part")
r = subprocess.run(["cjxl", inp, str(part), "-d", "0", "-e", opt["lossless_effort"], "--quiet",
"--num_threads=1"], input=data, capture_output=True)
if r.returncode:
part.unlink(missing_ok=True)
return keep_original("cjxl: " + r.stderr.decode(errors="replace").strip()[:200])
r = subprocess.run(["djxl", str(part), "-", "--output_format", "apng", "--quiet"], capture_output=True)
if r.returncode or not same_pixels(src, r.stdout, animated=n_frames > 1 or ext == ".gif"):
part.unlink(missing_ok=True)
return keep_original("pixel check failed")
if part.stat().st_size >= src.stat().st_size:
part.unlink(missing_ok=True)
return keep_original("JXL not smaller")
os.replace(part, dst)
copy_times(src, dst)
return src, dst, dst.stat().st_size, "ok", ""
except Exception:
dst.with_name(dst.name + ".part").unlink(missing_ok=True)
return keep_original(traceback.format_exception_only(*sys.exc_info()[:2])[-1].strip()[:200])
def work(a):
src, dst, keep, opt = a
try:
return (lossy if src.suffix.lower() in LOSSY_EXTS else lossless)(src, dst, keep, opt)
except Exception:
return src, dst, None, "fail", traceback.format_exception_only(*sys.exc_info()[:2])[-1].strip()[:300]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--distance", default="3")
ap.add_argument("--effort", default="5")
ap.add_argument("--lossless-effort", default="7")
ap.add_argument("--in-process", action="store_true",
help="encode JPEGs with libjxl inside Python (pillow-jxl-plugin) instead of running cjxl; "
"photos with an ICC profile still use cjxl")
ap.add_argument("--workers", type=int, default=os.cpu_count())
ap.add_argument("--limit", type=int)
ap.add_argument("--no-dedupe", action="store_true", help="convert every image, no duplicate detection")
ap.add_argument("--dry-run", action="store_true", help="only print folder roles and planned shortcuts")
ap.add_argument("--copy-max-mb", type=float, metavar="MB", default=COPY_MAX_MB,
help=f"copy non-image files up to this size as-is (default: {COPY_MAX_MB}, negative: don't copy)")
a = ap.parse_args()
sys.stdout.reconfigure(errors="replace") # file names the console code page can't show must not crash the run
opt = {"distance": a.distance, "effort": a.effort, "lossless_effort": a.lossless_effort,
"in_process": a.in_process}
all_files = sorted(p for p in SRC.rglob("*") if p.is_file() and not excluded(p))
files = [p for p in all_files if not is_always_copy(p) and p.suffix.lower() in LOSSY_EXTS | LOSSLESS_EXTS]
stems = Counter((p.parent, p.stem.lower()) for p in files)
out = {} # source -> (dst .jxl, kept original, shortcut)
for p in files:
rel = p.relative_to(SRC)
# IMG.jpg + IMG.png in one folder would both become IMG.jxl -> name them IMG.jpg.jxl / IMG.png.jxl
dst = DST / (rel.parent / (rel.name + ".jxl") if stems[p.parent, p.stem.lower()] > 1 else rel.with_suffix(".jxl"))
out[p] = (dst, DST / rel, dst.with_name(dst.name + ".lnk"))
def done_already(p):
dst, keep, lnk = out[p]
return dst.exists() or lnk.exists() or (p.suffix.lower() in LOSSLESS_EXTS and keep.exists())
DST.mkdir(parents=True, exist_ok=True)
pool = Pool(a.workers)
links = {}
if not a.no_dedupe:
recs = dedupe.build_index(pool, files, DST / "_index.pkl")
links, folders = dedupe.plan(recs)
for f, (n, dup, coll) in sorted(folders.items()):
if dup:
print(f"{'collection' if coll else 'original '} {f} ({dup}/{n} images also elsewhere)", flush=True)
print(f"dedupe: {len(links)} of {len(files)} images are duplicates or smaller versions -> shortcuts", flush=True)
if a.dry_run:
for p, m in sorted(links.items()):
print(f"link {p} -> {m}", flush=True)
return
skipped = sum(done_already(p) for p in files)
jobs = [(p, *out[p][:2], opt) for p in files if p not in links and not done_already(p)][: a.limit]
link_jobs = [p for p in files if p in links and not done_already(p)]
copied = copy_done = ignored = video_compressed = video_copied = 0
for p in all_files:
ext = p.suffix.lower()
is_img = ext in LOSSY_EXTS | LOSSLESS_EXTS
is_vid = ext in VIDEO_EXTS
in_always = is_always_copy(p)
dst = DST / p.relative_to(SRC)
size = p.stat().st_size
if is_img and not in_always:
continue
if in_always:
if dst.exists() and dst.stat().st_size == size:
copy_done += 1
continue
copy_file(p, dst)
copied += 1
print(f"copy {p} -> {dst} (always_copy, {size/1e3:.0f} kB)", flush=True)
continue
if is_vid:
if size <= copy_video_size:
if dst.exists() and dst.stat().st_size == size:
copy_done += 1
continue
copy_file(p, dst)
video_copied += 1
print(f"copy {p} -> {dst} (video <= {copy_video_size/1e6:.1f}MB, {size/1e6:.1f} MB)", flush=True)
else:
if dst.exists():
video_compressed += 1
print(f"skip {p} (compressed video already exists)", flush=True)
continue
print(f"compress {p} -> {dst} ({size/1e6:.1f} MB > {copy_video_size/1e6:.1f}MB)", flush=True)
try:
compress_video(p, dst, vcompress_cmd)
video_compressed += 1
except Exception as e:
print(f"FAIL {p} ffmpeg: {e}", flush=True)
continue
if a.copy_max_mb is None or a.copy_max_mb < 0 or size > a.copy_max_mb * 1e6:
ignored += 1
why = "copy disabled" if a.copy_max_mb is None or a.copy_max_mb < 0 else f"{size/1e6:.1f} MB > {a.copy_max_mb:g} MB"
print(f"skip {p} ({why})", flush=True)
continue
if dst.exists() and dst.stat().st_size == size:
copy_done += 1
continue
copy_file(p, dst)
copied += 1
print(f"copy {p} -> {dst} ({size/1e3:.0f} kB)", flush=True)
print(f"{len(files)} images found, {skipped} already done, {len(jobs)} to convert, {len(link_jobs)} shortcuts to make; "
f"videos: {video_compressed} compressed, {video_copied} copied; "
f"other files: {copied} copied, {copy_done} already copied, {ignored} skipped", flush=True)
log = open(DST / "failures.log", "a", encoding="utf-8")
c = convert_all(pool, jobs, log)
# shortcuts point at whatever the master became; if the master failed, convert the duplicate itself
made, orphans = 0, []
attempted = {j[0] for j in jobs}
for p in link_jobs:
m = links[p]
target = next((t for t in out[m][:2] if t.exists()), None)
if target is None:
if m in attempted:
orphans.append((p, *out[p][:2], opt))
# else: master not converted yet (--limit), the shortcut follows in a later run
continue
try:
dedupe.make_shortcut(out[p][2], target)
copy_times(p, out[p][2])
made += 1
print(f"link {p} -> {target}", flush=True)
except Exception as e:
orphans.append((p, *out[p][:2], opt))
log.write(f"LINK\t{p}\t{e}\n"); log.flush()
if orphans:
print(f"{len(orphans)} duplicates have no usable master -> converting them instead", flush=True)
c2 = convert_all(pool, orphans, log)
c = {k: c[k] + c2[k] for k in c}
pool.close()
print(f"done={c['done']} kept_original={c['kept']} shortcuts={made} already_done={skipped} failed={c['fail']} "
f"(see {DST / 'failures.log'})")
def convert_all(pool, jobs, log):
t0 = time.perf_counter()
c = {"done": 0, "kept": 0, "fail": 0}
out_bytes = in_bytes = 0
# results arrive in the main process one at a time, so console lines never interleave
for src, dst, size, status, msg in pool.imap_unordered(work, jobs, chunksize=2):
n = sum(c.values()) + 1
if status == "fail":
c["fail"] += 1
log.write(f"FAIL\t{src}\t{msg}\n"); log.flush()
print(f"[{n}/{len(jobs)}] FAIL {src} {msg}", flush=True)
else:
out_bytes += size
in_bytes += src.stat().st_size
if status == "keep":
c["kept"] += 1
log.write(f"KEEP\t{src}\t{msg}\n"); log.flush()
print(f"[{n}/{len(jobs)}] keep {src} -> {dst} (original kept: {msg})", flush=True)
elif status == "ok":
c["done"] += 1
print(f"[{n}/{len(jobs)}] ok {src} -> {dst} ({size/1e3:.0f} kB)", flush=True)
if n % 200 == 0 or n == len(jobs):
el = time.perf_counter() - t0
rate = n / el
eta = (len(jobs) - n) / rate if rate else 0
print(f"{n}/{len(jobs)} {rate:.1f} img/s kept={c['kept']} fail={c['fail']} "
f"out={out_bytes/1e6:.0f}MB ({out_bytes/max(in_bytes,1):.3f} of src) "
f"elapsed={el/60:.1f}min eta={eta/3600:.2f}h", flush=True)
return c
if __name__ == "__main__":
main()
Surround mischen in Audacity
Audacity kann mit Hilfe von FFMPEG Surround exportieren (und auch importieren). Das mapping ist etwas hakelig zu editieren.

Für 5.1 ac3 braucht es das Mapping:
FL
FR
C
S
SL
SR

Hier im Beispiel swappe ich F und R WAV Audio.
Aber Achtung: Sollte man nur einen Teil auf einer Spur ausgewählt haben, wird auch nur das exportiert. Die anderen Spuren sind dann leer!
Projectile Simulator
Ein ambitioniertes Python-Projekt.
# improvement todos# + use faster OCR# + find ground line# - and all tanks on it# + simulate underground path# + simulate bounding (game edges# - and pink obstacles)# - auto-scale based on game window size# - calculate high and low hit paths automatically# - simulate bouncing weps (only on flat ground yet)# OR# - based on current shot settings, adapt to hit nearest enemy tankBENCH_OCR = False#TrueOCRS = ["paddleocr", "winrt", "tesseract"]NO_PP = True # paddle is very robust and doesn't need it, preprocessing can fail, tooimport mathimport sysimport reimport getoptimport osimport timefrom timeit import default_timer as timerimport threadingfrom tkinter import *# ocr# keras won't run and conflicts with paddleif "doctr" in OCRS: # very slow: >4sfrom doctr.models import ocr_predictor # pip install python-doctrmodel = ocr_predictor(pretrained=True)from doctr.io import DocumentFileif "tesseract" in OCRS: # slow: >200msimport pytesseract # pip install pytesseractpytesseract.pytesseract.tesseract_cmd = r'C:\Storage\Tools\Tesseract-OCR\tesseract.exe' # xxx editif "winrt" in OCRS: # very fast: 12msfrom screen_ocr import Reader # pip install screen-ocr[winrt]ocr_reader = Reader.create_quality_reader()if "easyocr" in OCRS: # slow: >300msimport easyocr # pip install easyocreasy_reader = easyocr.Reader(['en'])if "paddleocr" in OCRS: # fast and accurate: 60msfrom paddleocr import PaddleOCR # pip install paddlepaddle, paddleocrfrom numpy import asarraypaddle_ocr = PaddleOCR(lang='en', show_log=False)import loggingfrom paddleocr.ppocr.utils.logging import get_logger as ppocr_get_loggerppocr_get_logger().setLevel(logging.ERROR)from PIL import ImageTkimport PIL.ImageOps# screenshotimport pyautogui# windowsimport ctypesfrom ctypes.wintypes import HWND, DWORD, RECTdef GetWindowRectFromName(name:str)-> tuple:hwnd = ctypes.windll.user32.FindWindowW(0, name)rect = ctypes.wintypes.RECT()try:ctypes.windll.user32.GetWindowRect(hwnd, ctypes.pointer(rect))return (rect.left+9, rect.top, rect.right-8, rect.bottom-11)except:return (0,0,1882,1421)gcoord = GetWindowRectFromName('ShellShock Live')# Optionsxoffs,yoffs,x2,y2 = gcoordwidth = x2-xoffsheight = y2-yoffsverbose = Falseauto = True # auto now default ondef printhelp():print(os.path.basename(__file__)+' [-x OFFSET] [-y OFFSET] [-v|--verbose] [-a|--noauto]')# print("MODES:\n - 0|1: default trajectory\n - 2: slow projectile\n - 3: hover ball\n - 4: straight\n - 5: 3d bomb")try:opts, args = getopt.getopt(sys.argv[1:], "hax:y:v", ["verbose","auto"])except getopt.GetoptError:printhelp()sys.exit()# print(opts)for opt, arg in opts:if opt == '-h':printhelp()sys.exit()elif opt in ("-x"):xoffs = int(arg)width = 1882 # fixed to yoga window sizeelif opt in ("-y"):yoffs = int(arg)height = 1421 # fixedelif opt in ("-v", "--verbose"):verbose = Trueelif opt in ("-a", "--noauto"):auto = False# ConfigGUI_SIZE = "950x800" # init value, size changes are stored to cfg fileWINDOW_OFFS = (xoffs, yoffs) # for moved windowIMG_SCALE = 2.0 # scale output downSHOW_CROPS = verbose # show cropped img for wind and paGAME_CRD = (WINDOW_OFFS[0], WINDOW_OFFS[1], width+WINDOW_OFFS[0], height+WINDOW_OFFS[1]) # game window# size-dependent# todo find on scaled windowPLAY_CRD = (5, 180, width-5, height-(1421-1270)) # playing fieldWEP_CRD = (700-5, 1375, 999, 1405) # weapon nameFIRE_CRD = (1070, 1300, 1070+1, 1300+1) # a pixel in fire buttonWIND_CRD = (924, 143, 958, 167) # wind speed - this is super sensitive to window x displacements!!!WINDR_X = 979 # wind dir indicatorsWINDL_X = 902WIND_Y = 152TANKY_OFFS = 2 # center of shot offs to found tank centerBARREL_LENGTH = 21.0BARREL_LENGTH_Y = 21.0 # todo get rid of different yBOUNDER_HEIGHT = 218 # side-boundersA_GND_RANGE = 20 # pixel to scan left&right to find gnd angle# unscaled valuesBOMB_T = 194HOVER_T = 155 # ca. 0.8sMAX_T = 2000 # max. flight simulation time ca 10sBOOMERANG_F = 486.0 # pseudo wind factor for boomerang wepsRAM_F = 4.9 # grav increase on apex for battering wepsBOUNCE_FACTOR = (0.31,0.51,0.71,0.81) # reduction of bouncing wep speed, (normal, med, high, ultra-bounce); bouncing is experimental - stone-decoration makes ground-detection imprecise, bouncing is only correct on flat ground# ballistic parametersSF = 0.04885#0.04222 # speed factorWF = 0.0000128#sf/8.12*power/100.0 # wind acc factorGACC = 9.803/1000.0 # earth accelerationSCAN_WEP = True # OCR wep nameCOLOR = [(255, 255, 255), (200, 200, 255)] # trajectory color right;leftAUTO = autoPRINT = not auto# read weapon listweps = []try:for l in open("weps.txt").readlines():weps.append(l.strip())except:print("Weapon list wep.txt not found")WEAPONS = weps#Create an instance of tkinter framewin = Tk()win.title("Shellshock Simulator")#Set the geometry of tkinter frameCONFIG_FILE = os.path.basename(__file__)+".conf"if os.path.isfile(CONFIG_FILE):#Here I read the X and Y positon of the window from when I last closed it.with open(CONFIG_FILE, "r") as conf:win.geometry(conf.read())else:#Default window position.win.geometry(GUI_SIZE)wind = StringVar()power = StringVar()angle = StringVar()bfactor = DoubleVar()bfactor.set(0.3)subwep = IntVar()bounce = IntVar()bound = IntVar()autowep = IntVar()override = IntVar()md = IntVar() # modemd.set(0)autowep.set(1)sfv = DoubleVar()sfv.set(SF)wfv = DoubleVar()wfv.set(WF)gaccv = DoubleVar()gaccv.set(GACC)def ocr_winrt(im):return ocr_reader.read_image(im).as_string()def ocr_tesser(img, cfg='--psm 7 -c tessedit_char_whitelist=0123456789-,'):return pytesseract.image_to_string(img, config=cfg)def ocr_easy(im):im.save('temp.png')result = easy_reader.readtext('temp.png')r = ""for (bbox, text, prob) in result:r += textreturn rdef ocr_paddle(im):numpydata = asarray(im)result = paddle_ocr.ocr(numpydata)r = ""for idx in range(len(result)):res = result[idx]try:for line in res:r += line[1][0]except:pass#print(res) # reader failed; returned Nonereturn rdef ocr_doctr(im):im.save('temp.png')doc = DocumentFile.from_images("temp.png")result = model(doc)print(result)return ""def ocr(im, method=OCRS[0], cfg=''):if method == "paddleocr":return ocr_paddle(im)elif method == "winrt":return ocr_winrt(im)elif method == "tesseract":return ocr_tesser(im, cfg)elif method == "easyocr":return ocr_easy(im)elif method == "doctr":return ocr_doctr(im)return ""def distance(p1, p2):return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)def preprocess(img):if NO_PP:return imgsize_x, size_y = img.sizeimg = img.resize((size_x*2, size_y*2), 3)# img = PIL.ImageOps.invert(img)# if SHOW_CROPS:# img.show()# img = img.resize(((pow_rect[2]-pow_rect[0])*2, (pow_rect[3]-pow_rect[1])*2), Image.ANTIALIAS)pixdata = img.load()d = 16for y in range(0, img.size[1]):for x in range(0, img.size[0]):r, g, b = pixdata[x, y]#splitp = 120#repl = 200#if (r in range(0, splitp)) or (g in range(0, splitp)) or (b in range(0, splitp)):# if (r in range(splitp,255)) and (g in range(splitp,255)) and (b in range(splitp,255)):if r in range(b-d, b+d) and g in range(b-d, b+d):# and g>150:# (r in range(0, splitp)) or (g in range(0, splitp)) or (b in range(0, splitp)):#b > g*1.5 and r > g*1.5:#g > 160:#pixdata[x, y] = (b//2, b//2, b//2)else:pixdata[x, y] = (0, 0, 0)#~ if (r in range(splitp, 256)) and (g in range(splitp, 256)) and (b in range(splitp, 256)):#~ pixdata[x, y] = (255, 255, 255)return PIL.ImageOps.invert(img)def postprocess(pa):try:p, a = re.split('[^0123456789-]+', pa.strip())if re.search(r"\d+", p.strip()) and re.search(r"\d+", a.strip()): # check if output is 2 numbersreturn (p, a)except:passreturn ""img_pa = ""def get_pow_ang(im, pow_rect):global img_paimg = im.crop(pow_rect)#.convert('L')#.resize()#.invert()img = preprocess(img)if SHOW_CROPS:img.show()img_pa = ImageTk.PhotoImage(img)pa = ocr(img)return patry:p, a = re.split('[^0123456789-]+', pa.strip())if re.search(r"\d+", p.strip()) and re.search(r"\d+", a.strip()): # check if output is 2 numbersreturn paexcept:passreturn ""def get_pow_ang_t(im, pow_rect, ocr_method = "tesseract"):global img_paimg = im.crop(pow_rect)img = preprocess(img)if SHOW_CROPS:img.show()img_pa = ImageTk.PhotoImage(img)pow_ang = ocr(img, ocr_method)#pytesseract.image_to_string(img, config='--psm 7 -c tessedit_char_whitelist=0123456789-,')return pow_angdef find_pa(im, tx, ty):im_ = im.load()limit = 210px = []for x in range(tx+30, tx+130):try:r,g,b = im_[x, ty+5]except:r,g,b = 0,0,0if r>limit and g>limit and b>limit:px.append(x)if len(px) > 2: # found on right sidereturn (min(px)-10, ty-10, max(px)+10, ty+30)for x in range(tx-130, tx-30):try:r,g,b = im_[x, ty+5]except:r,g,b = 0,0,0if r>limit and g>limit and b>limit:px.append(x)if len(px) > 2: # found on left sidereturn (min(px)-10, ty-10, max(px)+10, ty+30)return (tx-64, ty+25, tx+64, ty+70) # extended x bc on edges the display jumps a bit to the middleimg_wind = ""def get_wind(im, wind_rect):global img_windimg = im.crop(wind_rect)if SHOW_CROPS:img.show()img_wind = ImageTk.PhotoImage(img)dircheck = im.load()splitp = 90cnt = 0sign = 0# print('sign:')# print(dircheck[WINDL_X, WIND_Y])r, g, b = dircheck[WINDL_X, WIND_Y]if (r in range(0, splitp)) or (g in range(0, splitp)) or (b in range(0, splitp)): # black -> no arrowsign = 1cnt += 1# print(dircheck[WINDR_X, WIND_Y])r, g, b = dircheck[WINDR_X, WIND_Y]if (r in range(0, splitp)) or (g in range(0, splitp)) or (b in range(0, splitp)):sign = -1cnt += 1if cnt == 2: # no arrow seenif PRINT:print("no wind")return 0for o in OCRS:w = ocr(img, o)try:if w != "":w = int(w)return sign*int(w)except:passif PRINT:print("failed to read wind")return 0def calc_dir(gnd, x, wx):dir = 0try:if gnd[wx] < gnd[x]: # smaller means updir = 1if gnd[wx] > gnd[x]:dir = -1except:passreturn dirdef calc_gnd_angle(gnd, x):wx = xif x > 1: # walk backwards for N pixels as long as they are monotonouswx = x-1dir = calc_dir(gnd, x, wx)while wx>1 and x-wx < A_GND_RANGE and calc_dir(gnd, wx, wx-1) in [0,dir]:wx -= 1leftx = wxwx = xif x < len(gnd)-1: # walk forwards for N pixels as long as they are monotonouswx = x+1dir = calc_dir(gnd, x, wx)while wx<len(gnd)-1 and wx-x < A_GND_RANGE and calc_dir(gnd, wx, wx+1) in [0,dir]:wx += 1rightx = wxdx = rightx - leftxdy = gnd[rightx] - gnd[leftx]a = math.atan2(-dy, dx)if a >= math.pi/2.0:a -= math.pireturn (a, dx, dy)def calc_refl_angle(a, v0x, v0y):v1x = v0x*math.cos(2*a) + v0y*math.sin(2*a)v1y = v0x*math.sin(2*a) - v0y*math.cos(2*a)return (v1x, v1y)img = "" # keep img reference alivedef sim_shot(im, x, y, power, angle, wind, weapon, gnd):global imgslow_weps = ('Baby Seagull', 'Mama Seagull', 'Seagull', 'BFG-1000', 'BFG-9000', 'Chopper', 'Apache')straight_weps = ('AK-47', 'M4', 'M16', 'Glock', 'M9', 'Desert Eagle', 'UZI', 'MP5', 'P90', 'Laser Beam', 'Great Beam', 'Ultra Beam', 'Master Beam', 'Flintlock', 'Blunderbuss', 'Fat Stacks', 'Dolla Bills', 'Make-it-Rain')submersive_weps = ('Sub-Sniper', 'Tunneler', 'Mega-Tunneler', 'Torpedoes', '3D-Bomb', 'Rainbow', 'MegaRainbow', 'Dead Riser', 'Yin Yang', 'Yin Yang Yong', 'Penetrator', 'Penetrator v2', 'Sausage', 'Bratwurst', 'Kielbasa')noprojectile_weps = ('Earthquake', 'Mega-Quake', 'Shank', 'Dagger', 'Sword', 'Attractoids', 'Shockwave', 'Sonic Pulse', 'Rampage', 'Riot', 'Imploder', 'Ultimate Imploder', 'ShockShell', 'ShockShell Trio')hover_wep = ('Hover-Ball', 'Heavy Hover-Ball')drei_d_wep = ('3D-Bomb', '2x3D', '3x3D') # wind affects those, too!boomerang_wep = ('Boomerang', 'BigBoomerang')taser_wep = ('Taser', 'Heavy Taser')ram_wep = ('Battering Ram', 'Double Ram', 'Ram-Squad', 'Double Ram-Squad')ultra_bounce_wep = ('Snowball','Bounsplode','Double-Bounsplode','Triple-Bounsplode')highbounce_wep = ('One-Bounce', 'Three-Bounce', 'Five-Bounce', 'Seven-Bounce','Stone','Boulder','Fireball')medium_bounce_wep = ('Grenade', 'Tri-Nade', 'Multi-Nade','Sillyballs','Wackyballs','Crazyballs','Beacon','Beaconator','Jack-o-Lantern','Jack-o-Vomit')bounce_wep = ('Grenade Storm', 'Cactus Strike', 'Tunnel Strike', 'Air Strike','Helicopter Strike','AC-130','Artillery', 'Bolt','Lightning','2012','Disco Ball','Groovy Ball','Water Balloon','Water Trio','Water Fight','Pinata','Fiesta','Mine Layer','Sticky Rain','Rain','Hail','FireStorm','Shrapnel','Shredders','Carpet Bomb','Carpet Fire','Incendiary Bombs','Snowstorm','Bounstream','Bounstrike','Shooting Star','Kamikaze','Suicide Bomber','Acid Rain','Acid Hail','Recruiter','Enroller','Enlister','Cats and Dogs','Satellite','UFO','Kernels','Popcorn','Burnt Popcorn','Pinpoint','Needles','Pins and Needles','God Rays','Deity')if PRINT:print("SIM Power & Angle: ", power, angle)print("SIM Wind: ", wind)if weapon in noprojectile_weps:returnsize_x, size_y = im.sizets = BARREL_LENGTH # tank gun offsettsy = BARREL_LENGTH_Ysf = sfv.get()#SFwf = wfv.get()#WFapogee_cnt_init = 0max_t = MAX_Tgacc = gaccv.get()#GACCgacc_sub = gacc*10.0/9.803#GACC_SUBtasermax = 1taserangle = [angle, angle]bouncing = Falsebf = 0.0 # boomerangram = Falseram_active = Falseif weapon in slow_weps or md.get() == 2:#md.set(2)if PRINT:print("SLOW")sf = sf/2.0wf = wf/2.0#WF_SLOWgacc = gacc/4.0gacc_sub = gacc_sub/4.0elif weapon in hover_wep or md.get() == 3:#md.set(3)if PRINT:print("HOVER")apogee_cnt_init = int(HOVER_T)if weapon == "Heavy Hover-Ball":apogee_cnt_init = apogee_cnt_init*10//8# Heavy flies 1.0s, normal 0.8selif weapon in straight_weps or md.get() == 4:#md.set(4)if PRINT:print("STRAIGHT")gacc = 0.0gacc_sub = 0.0wind = 0.0elif weapon in drei_d_wep or md.get() == 5: # 3d bomb is different with wind, not quite right yet (shot up/dn is deviating more in real game)#md.set(5)if PRINT:print("3D")gacc = 0.0gacc_sub = 0.0# wf = 0.00005max_t = BOMB_T # limit flight timeelif weapon in taser_wep or md.get() == 6:#md.set(6)if PRINT:print("TASER")tasermax = 2taserangle = [angle-5.7, angle+5.7]elif weapon in bounce_wep or md.get() == 7:#md.set(7)if PRINT:print("BOUNCING")if override.get() == 0:bfactor.set(BOUNCE_FACTOR[0])bouncing = Truemax_t = 2*max_telif weapon in medium_bounce_wep or md.get() == 11:#md.set(11)if PRINT:print("MEDIUM-BOUNCING")if override.get() == 0:bfactor.set(BOUNCE_FACTOR[1])bouncing = Truemax_t = int(1.5*max_t)elif weapon in highbounce_wep or md.get() == 8:#md.set(8)if PRINT:print("HIGH-BOUNCING")if override.get() == 0:bfactor.set(BOUNCE_FACTOR[2])bouncing = Truemax_t = 2*max_telif weapon in ultra_bounce_wep or md.get() == 12:#md.set(12)if PRINT:print("ULTRAHIGH-BOUNCING")if override.get() == 0:bfactor.set(BOUNCE_FACTOR[3])bouncing = Truemax_t = 2*max_telif weapon in boomerang_wep or md.get() == 9:#md.set(9)sx = power*math.cos(angle*math.pi/180.0)bf = wf*BOOMERANG_F*sx/(100.0*math.cos(45.0*math.pi/180.0))if PRINT:print("BOOMERANG",sx,bf)elif weapon in ram_wep or md.get() == 10:#md.set(10)ram = True # Trigger grav increase on apexgacc_ram = gacc*RAM_Fif PRINT:print("RAM")else:pass#md.set(0)sw = subwep.get() != 0if weapon in submersive_weps:sw = Truepixdata = im.load()pixdata[x, y] = (255, 0, 0)pixdata[x+1, y+1] = (255, 0, 0)pixdata[x+1, y] = (255, 0, 0)pixdata[x, y+1] = (255, 0, 0)#bcnt=0 # dbgfor taserp in range(0, tasermax):if tasermax == 2:angle = taserangle[taserp]for q in range(0, 2): # ugly workaround for aiming dir (not unique)sx = sf*power*math.cos(angle*math.pi/180.0)sy = sf*power*math.sin(angle*math.pi/180.0)lx = x+ts*math.cos(angle*math.pi/180.0)if q==1:lx = x-ts*math.cos(angle*math.pi/180.0)ly = y-tsy*math.sin(angle*math.pi/180.0)p = [[lx, ly], [lx, ly]]renderbuf = [(0, 0)]*max_trbcnt = 0windacc = 0.0rfcooldown = 0bouncing_cnt = 0hover = Falseapogee_cnt = apogee_cnt_initfor t in range(0, max_t):oldsy = syif q==0:p[q][0] += sxelse:p[q][0] -= sxp[q][0] += windaccif q==0:bff=-bfwindacc += wind*wf + bffif not hover:p[q][1] -= syix = int(p[q][0]+0.5)iy = int(p[q][1]+0.5)# if q==0:# print(ix, iy, gnd[ix], sy)if bouncing: # hitting the ground bounces the weaponif ix<0 or ix>=size_x-1 or iy<=gnd[ix] or bouncing_cnt>0 or sy>0: # shot in field and in air, upwards, or bouncing cooldownif bouncing_cnt>0:bouncing_cnt-=1else:# print("bouncing")bouncing_cnt=5p[q][1] = gnd[ix]+1 # ugly hack to prevent endless bouncinggnd_angle,dx,dy = calc_gnd_angle(gnd, ix)# calc entry/exit angles and modify sx,sysxn, syn = calc_refl_angle(gnd_angle, sx, sy)#if q==0 and bcnt==0:# bcnt+=1# print("angle:", gnd_angle*180/math.pi,dx,dy)# print("in:",sx,sy)# print("out:",sxn,syn)sy = syn * bfactor.get()#BOUNCE_FACTOR[btype]sx = sxn * bfactor.get()#BOUNCE_FACTOR[btype]sy -= gacc # normal flight curveelif sw:if ix<0 or ix>=size_x-1 or iy<=gnd[ix]: # shot in field and in airif not hover:sy -= gaccelse:sy += gacc_sub # underground# print("sub")else:if not hover:sy -= gaccif oldsy>0 and sy<0 and apogee_cnt>0:hover = True# print("Apogee detected", t)if ram and oldsy>0 and sy<0:ram_active = Trueif ram_active:gacc = gacc_ramif hover:apogee_cnt -= 1if apogee_cnt == 0:hover = Falseif bound.get()!=0 and ix >= size_x and rfcooldown==0:# and iy in range(gnd[-1]-BOUNDER_HEIGHT,gnd[-1]+BOUNDER_HEIGHT): # right boundersx = -sxrfcooldown = 10windacc = -windaccif bound.get()!=0 and ix <= 0 and rfcooldown==0:# and iy in range(gnd[0]-BOUNDER_HEIGHT,gnd[0]+BOUNDER_HEIGHT): # left boundersx = -sxrfcooldown = 10windacc = -windaccif rfcooldown>0:rfcooldown -= 1if ix in range(0, size_x) and iy in range(0, size_y):renderbuf[rbcnt] = (ix, iy, COLOR[q])rbcnt += 1if ix not in range(-10, size_x+10): # end meaningless calculationbreakif iy > size_y+10 and sy < 0: # end meaningless calculationbreakfor t in range(0, rbcnt):ix, iy, c = renderbuf[t]pixdata[ix, iy] = c # draw curvetry:pixdata[ix, iy+1] = c # draw curvepixdata[ix+1, iy] = c # draw curvepixdata[ix+1, iy+1] = c # draw curveexcept:passif verbose:im.save("sss.jpg")im_r = im.resize((int(size_x/IMG_SCALE), int(size_y/IMG_SCALE)), 3)img = ImageTk.PhotoImage(im_r)canvas.create_image(0,0,anchor=NW,image=img)def find_tank(im):im_tank = im.load()found = Falsex = 0y = 0size_x, size_y = im.sizefor y in range(size_y-1, 0, -2):for x in range(0, size_x, 2):r, g, b = im_tank[x, y]if (r in range(100, 140)) and (g in range(200, 255)) and (b in range(100, 140) and (g-b > 90)): # color matching the green tanktank_at = (x, y)found = Truebreakif found:breakif found==False:return (0, 0)p = [[0]*2]*40*40cnt = 0for xx in range(x-40, x+40):for yy in range(y-40, y+40):if xx>=0 and yy>=0 and xx<size_x and yy<size_y:r, g, b = im_tank[xx, yy]if (r in range(0, 127)) and (g in range(160, 255)) and (b in range(0, 127) and (g-b > 100)):try:p[cnt] = [xx, yy]# im_tank[xx,yy] = (255,0,0) # highlight tank pixelscnt += 1except:passif cnt > 0:xsum = 0ysum = 0for pp in p:xsum += pp[0]ysum += pp[1]xsum /= cntysum /= cntrx, ry = int(xsum+0.5), int(ysum+0.5) + TANKY_OFFS# im_tank[rx,ry] = (0,0,0) # use this to precision-match the center# im_tank[rx+1,ry] = (0,0,0)# im_tank[rx,ry+1] = (0,0,0)# im_tank[rx+1,ry+1] = (0,0,0)# im.show()return (rx, ry)return (0, 0)def find_gnd_color(im, size_x, size_y): # find the general color of the ground, possible improvements: sample more, only take majority color# try left bottomcol = im[10, size_y-10]if col[0] in range(50,255) or col[1] in range(50,255) or col[2] in range(50,255): # with the risk that a tank or obstacle is therereturn colcol = im[size_x//2, size_y-10]if col[0] in range(50,255) or col[1] in range(50,255) or col[2] in range(50,255): # with the risk that a tank or obstacle is therereturn colcol = im[size_x-10, size_y-10]if col[0] in range(50,255) or col[1] in range(50,255) or col[2] in range(50,255): # with the risk that a tank or obstacle is therereturn coldef check_col(p, col, dn=-20, dp=50):if p[0] in range(col[0]-dn,col[0]+dp) and p[1] in range(col[1]-dn,col[1]+dp) and p[2] in range(col[2]-dn,col[2]+dp):return Truereturn Falsedef find_y_border(im, col, x, size_x, size_y):y = size_y-1 # scan from bottom -> upen = Falsecollimit = 120enlimit = 200if x >= size_x-25 or x <= 25: # darkened border reagoncollimit = 70enlimit = 100while y>0:p = im[x, y]if p[2] > enlimit:en = Trueif en and p[2] < collimit:# massive decrease in blue brightness -- check_col(p, col, -20, 50): # already gnd colorreturn y+1y -= 1#xxx find tanksreturn size_y-1def find_ground(im):im_gnd = im.load()size_x, size_y = im.sizeground = [-1]*size_xgnd_col = find_gnd_color(im_gnd, size_x, size_y)if PRINT:print("Ground color: ", gnd_col)for x in range(0,size_x): # todo multithreaded loop to speed upy = find_y_border(im_gnd, gnd_col, x, size_x, size_y)# print(x,y)# im_gnd[x, min(y,size_y-1)] = (255,0,0)# im_gnd[x, min(y+1,size_y-1)] = (255,0,0)# im_gnd[x, min(y+2,size_y-1)] = (255,0,0)ground[x] = y# im.show()# interpolate missing gnd?return groundim_play = ""running = Falsedef run_click():global running# run()# returnif running:btn_run["text"] = "Run"running = Falseelse:btn_run["text"] = "Stop"running = Truerun()img_wep = ""def run():global im_play, img_wep# global wind, weapon, power, angleim = pyautogui.screenshot()im = im.crop(GAME_CRD)if AUTO:f_rect = FIRE_CRDim_f = im.crop(f_rect)im_fire = im_f.load()c = im_fire[0, 0]r, g, b = c# print((r,g,b))if not (r>220 and g<40 and b<40):#r==g and g==bif running:# and not stop_event.is_set():win.after(10, run)returnif override.get()==0:#wind == "":wind_rect = WIND_CRD#(946, 102, 943+29, 102+18)wind.set(f"{get_wind(im, wind_rect)}")weapon = "not scanned"if autowep.get() != 0:wep_rect = WEP_CRDim_wep = im.crop(wep_rect)img_wep = ImageTk.PhotoImage(im_wep)for o in OCRS:weapon = ocr(im_wep, o, '--psm 7').strip()if weapon in WEAPONS:breakif SHOW_CROPS:im_wep.show()lw["text"] = weaponplay_rect = PLAY_CRD#(264, 34, 264+1393, 71+854)im_play = im.crop(play_rect)#start = timer()# find green tank's x and yx, y = find_tank(im_play)if y==0:if PRINT:print("Tank not found!")if PRINT:print("Tank at: ", x, y)gnd = find_ground(im_play)#print("find stuff:",timer()-start)canvas.delete("all")if y != 0:# pow_rect = ((max(0, x-42), y+22, min(x+42, size_x), min(y+54+17, size_y)), (max(0, x-105), y-10, max(1, x-20), min(y+20, size_y))) # match the 2 possible positions# for pr in pow_rect:pr = find_pa(im_play, x, y)# print(pr,x,y)if pr[1] < pr[3]:col = "#888"fnt = "Tahoma 14"ocrm = OCRS#["paddleocr", "winrt", "tesseract"]for m in ocrm: # sorted by accuracytry:pa = get_pow_ang_t(im_play, pr, m).strip()pap = postprocess(pa)except:pap = ""if pap != "" and not BENCH_OCR: # sim shotp, a = papif p.strip() != "" and a.strip() != "":# try:if override.get() == 0: #power == "":power.set(f"{int(p)}")if override.get() == 0: #angle == "":angle.set(f"{int(a)}")sim_shot(im_play, x, y, int(power.get()), int(angle.get()), int(wind.get()), weapon, gnd)if img_pa != "":canvas.create_image(5, 5, anchor=NW, image=img_pa)canvas.create_text(180, 20, text=f"{pa}", fill=col, font=fnt)break# except:if PRINT:print("invalid pa")else:if PRINT:print("invalid pa")if BENCH_OCR: # show ocr activityif img_wind != "":canvas.create_image(10, 5, anchor=NW, image=img_wind)canvas.create_text(150, 14, text=f"{wind.get()}", fill=col, font=fnt)ox = 350for o in OCRS:start = timer()pa = get_pow_ang_t(im_play, pr, o).strip()t = "{:1.1f}".format((timer()-start)*1000.0)canvas.create_text(ox, 80, text=o+f":\n{pa}\n{t}", fill=col, font=fnt)ox += 145if img_pa != "":canvas.create_image(10, 80-40, anchor=NW, image=img_pa)if img_wep != "":canvas.create_image(250, 0, anchor=NW, image=img_wep)if AUTO:if running:# and not stop_event.is_set():win.after(10, run)x=""stop_event = threading.Event()def run_auto():while(True):run()time.sleep(0.05)if stop_event.is_set():breakdef save():im_play.save("sss.jpg")#Create a canvassize_x = PLAY_CRD[2]-PLAY_CRD[0]size_y = PLAY_CRD[3]-PLAY_CRD[1]canvas = Canvas(win, width=int(size_x/IMG_SCALE), height=int(size_y/IMG_SCALE))canvas.pack()btn_run = Button(win, text = "Run", width = 200, command = run_click)btn_run.pack()f1 = Frame(win)Checkbutton(f1, text="Enable Underground", variable=subwep).pack(side='left')Checkbutton(f1, text="Enable Bounders", variable=bound).pack(side='left')Checkbutton(f1, text="Detect Weapon", variable=autowep).pack(side='left')Label(f1, text="SF:").pack(side=LEFT)Entry(f1, textvariable=sfv, width=10).pack(side=LEFT)Label(f1, text="WF:").pack(side=LEFT)Entry(f1, textvariable=wfv, width=10).pack(side=LEFT)Label(f1, text="G:").pack(side=LEFT)Entry(f1, textvariable=gaccv, width=10).pack(side=LEFT)f1.pack(fill='x', expand=True)f2 = Frame(win)lw = Label(f2, text="Weapon:")lw.pack(side=LEFT)Label(f2, text="Wind:").pack(side=LEFT)Entry(f2, textvariable=wind, width=4).pack(side=LEFT)Label(f2, text="Power:").pack(side=LEFT)Entry(f2, textvariable=power, width=4).pack(side=LEFT)Label(f2, text="Angle:").pack(side=LEFT)Entry(f2, textvariable=angle, width=4).pack(side=LEFT)Label(f2, text="Bounce:").pack(side=LEFT)Entry(f2, textvariable=bfactor, width=4).pack(side=LEFT)Checkbutton(f2, text="Override", variable=override).pack(side=LEFT)Button(f2, text = "Save", command = save).pack(side=LEFT)f2.pack(fill='x', expand=True)f3 = Frame(win)Radiobutton(f3, text="Normal", variable=md, value=0).pack(side=LEFT)Radiobutton(f3, text="Slow", variable=md, value=2).pack(side=LEFT)Radiobutton(f3, text="Hover", variable=md, value=3).pack(side=LEFT)Radiobutton(f3, text="3D", variable=md, value=5).pack(side=LEFT)Radiobutton(f3, text="Taser", variable=md, value=6).pack(side=LEFT)Radiobutton(f3, text="Bounce", variable=md, value=7).pack(side=LEFT)Radiobutton(f3, text="Med-", variable=md, value=11).pack(side=LEFT)Radiobutton(f3, text="High-", variable=md, value=8).pack(side=LEFT)Radiobutton(f3, text="Ultra-Bounce", variable=md, value=12).pack(side=LEFT)Radiobutton(f3, text="Boomerang", variable=md, value=9).pack(side=LEFT)Radiobutton(f3, text="Ram", variable=md, value=10).pack(side=LEFT)f3.pack(fill='x', expand=True)def on_close():global x, stop_eventif AUTO:stop_event.set()#x.join()with open(CONFIG_FILE, "w") as conf:conf.write(win.geometry())win.destroy()win.protocol("WM_DELETE_WINDOW", on_close)def key_handler(event):if event.keycode == 27:sys.exit()if event.char == "r" or event.keysym == "space":run_click()if event.char == "s":save()if event.char == "o":override.set(1-override.get())if event.char == "b":bound.set(1-bound.get())if event.char == "u":subwep.set(1-subwep.get())# print(event.char, event.keysym, event.keycode)win.bind("<Key>", key_handler)#if AUTO:# x = threading.Thread(target=run_auto)# x.start()# loopwin.mainloop()stop_event.set()#~ # the math solution#~ # calc shellshock#~ C = 573.0/(0.73 * 2.58 * math.cos(math.pi/4.0))#~ G = (0.73 * math.sqrt(2.0) * C)/2.58#~ D = 446.0 # Durchmesser Zielkreis#~ B = 446.0/D/1.53 # screen correction#S = 2.82/1.51#~ def cr(x, y, angle):#~ x = B*x#~ y = B*y#~ ang = angle*math.pi/180.0#~ num = G*x*x#~ den = C*C*(x*math.sin(2.0*ang) - 2.0*y*(math.cos(ang) ** 2.0))#~ k = math.sqrt(num/den)#~ return k * 100.0#~ print(cr(385, -226, 30))
Bassline 2s digital toothpick


Bassline Digital HD 2S toothpick FPV drone built-in Walksnail or HDZERO VTX
Etwa 48g, schwebt 4:30min an 2s 450mAh.
Die originalen Gemfan 2023 triblade props sind besser geeignet an den 11000kv Motoren.
Die FC ist anders als beschrieben mit einem vollwertigen seriellen ELRS Empfänger bestückt, der sich über WIFI updaten lässt! Kein Flashen der BF-Software nötig!!!
DIY Sub-35g Digital Whoop
I built a small 1s digital whoop with WalkSnail.

Based on a Mobula 7 frame, T-Motor F411 AIO 13A 1s, RcINpower 1002 motors with Gemfan 1610 props, BetaFPV ELRS lite and the Walksnail VTX lite.
It weighs 34g, a 1s Lipo 550mAh has 14g. Flight-time with Tattu 1s 500mAh is 3:15. The GNB claiming 550mAh are only at 2:45.
UART1 = ELSR, UART2 = WalkSnail (CRSF), the motors are directly soldered. I used the original BLHeli_32 suite to fix the motor directions. The canopy is from https://www.printables.com/de/model/504099-rotorriot-vision40-prints.



OSD activation is a bit tricky:
set osd_displayport_device = MSP
set displayport_msp_serial = 1
Complete Diff:
# diff all
# version
# Betaflight / STM32F411 (S411) 4.3.1 Jul 13 2022 / 03:32:52
(8d4f005) MSP API: 1.44
# config: manufacturer_id: TMTR, board_name: TMOTORF411,
version: edcd244b, date: 2022-06-29T02:24:46Z
# start the command batch
batch start
# reset configuration to default settings
defaults nosave
board_name TMOTORF411
manufacturer_id TMTR
mcu_id 003700413236510435383430
signature
# name: V40
# feature
feature -RX_PARALLEL_PWM
feature -LED_STRIP
feature TELEMETRY
# serial
serial 0 64 115200 57600 0 115200
serial 1 1 115200 57600 0 115200
# beeper
beeper -ON_USB
# beacon
beacon RX_LOST
beacon RX_SET
# map
map TAER1234
# aux
aux 0 0 0 1750 2100 0 0
aux 1 1 1 1700 2100 0 0
aux 2 13 2 1700 2100 0 0
aux 3 35 2 1750 2100 0 0
# master
set gyro_lpf1_static_hz = 0
set gyro_lpf2_static_hz = 0
set dyn_notch_count = 2
set dyn_notch_min_hz = 100
set dyn_notch_max_hz = 1000
set gyro_lpf1_dyn_min_hz = 0
set gyro_lpf1_dyn_max_hz = 750
set acc_calibration = 55,52,-4,1
set mag_hardware = NONE
set baro_hardware = NONE
set rc_smoothing_auto_factor = 80
set rc_smoothing_setpoint_cutoff = 25
set rc_smoothing_feedforward_cutoff = 25
set serialrx_provider = CRSF
set rx_spi_protocol = EXPRESSLRS
set dshot_bidir = ON
set motor_pwm_protocol = DSHOT300
set motor_poles = 12
set align_board_pitch = -4
set vbat_max_cell_voltage = 440
set ibata_scale = 400
set ibata_offset = -1700
set beeper_dshot_beacon_tone = 3
set yaw_motors_reversed = ON
set small_angle = 180
set pid_process_denom = 1
set simplified_gyro_filter_multiplier = 150
set osd_cap_alarm = 450
set osd_vbat_pos = 2432
set osd_link_quality_pos = 2048
set osd_tim_2_pos = 2080
set osd_throttle_pos = 2368
set osd_current_pos = 2464
set osd_mah_drawn_pos = 2528
set osd_display_name_pos = 0
set osd_warnings_pos = 14355
set osd_displayport_device = MSP
set debug_mode = GYRO_SCALED
set displayport_msp_serial = 1
set rpm_filter_harmonics = 2
set name = V40
profile 0
# profile 0
set vbat_sag_compensation = 100
set anti_gravity_gain = 10000
set iterm_relax_cutoff = 10
set p_pitch = 67
set i_pitch = 120
set d_pitch = 76
set f_pitch = 199
set p_roll = 64
set i_roll = 115
set d_roll = 67
set f_roll = 191
set p_yaw = 64
set i_yaw = 115
set f_yaw = 191
set d_min_roll = 67
set d_min_pitch = 76
set thrust_linear = 40
set feedforward_averaging = 2_POINT
set feedforward_smooth_factor = 45
set feedforward_jitter_factor = 10
set dyn_idle_min_rpm = 35
set simplified_master_multiplier = 160
set simplified_d_gain = 140
set simplified_pi_gain = 90
set simplified_dmax_gain = 0
profile 1
# profile 1
set dterm_lpf1_dyn_min_hz = 100
set dterm_lpf1_dyn_max_hz = 125
set dterm_lpf1_type = BIQUAD
set dterm_lpf2_static_hz = 0
set vbat_sag_compensation = 100
set anti_gravity_gain = 4500
set crash_recovery = ON
set iterm_relax_cutoff = 12
set p_pitch = 58
set i_pitch = 83
set d_pitch = 70
set f_pitch = 124
set p_roll = 56
set i_roll = 79
set d_roll = 61
set f_roll = 119
set p_yaw = 56
set i_yaw = 79
set f_yaw = 119
set d_min_roll = 52
set d_min_pitch = 59
set thrust_linear = 40
set dyn_idle_min_rpm = 60
set simplified_master_multiplier = 125
set simplified_i_gain = 80
set simplified_d_gain = 140
set simplified_dmax_gain = 50
set simplified_feedforward_gain = 80
set simplified_dterm_filter = OFF
profile 2
# restore original profile selection
profile 1
rateprofile 0
# rateprofile 0
set thr_mid = 42
set thr_expo = 30
set rates_type = BETAFLIGHT
set roll_rc_rate = 100
set pitch_rc_rate = 100
set yaw_rc_rate = 100
set roll_expo = 40
set pitch_expo = 40
set roll_srate = 80
set pitch_srate = 80
set yaw_srate = 80
rateprofile 1
# rateprofile 1
set rates_type = BETAFLIGHT
set roll_rc_rate = 80
set pitch_rc_rate = 80
set yaw_rc_rate = 80
set roll_expo = 40
set pitch_expo = 40
set roll_srate = 70
set pitch_srate = 70
set yaw_srate = 70
rateprofile 2
rateprofile 3
rateprofile 4
rateprofile 5
# restore original rateprofile selection
rateprofile 0
# save configuration
save
Copter-Tuning revisited
Es gibt einen neuen Tuning-Guide von Chris Rosser:
Vorbereitung
- Flugfähiger Copter mit BF 4.3 oder 4.4
- RC-Link-Preset angewandt
- Blackbox-Logging auf 1/2 PID-Loop-Speed mit GYRO_SCALED
- Bi-Directional DSHOT, d.h. BLHeli32 oder Bluejay (die Anzahl Pole richtig setzen: 12 für kleine Motoren, 14 für normale)
- Optional OSD, zum PID-Tunen ohne PC
Filter-Tuning
Chris schaltet alle Gyro-Lowpass-Filter ab, außer den dynamischen und die RPM-Filter. Nur einen D-Term-Lowpass und den dafür auf BIQUAD. Die Grenzfrequenzen hängen von der Coptergröße ab, ein 40mm bis 2″ sollte so 100 und 125Hz haben. Die Filter kann man im Blackbox-Explorer überprüfen.
Der Testflug sollte einige 100%-Throttle, Vollgas-Punchouts haben.
PD-Tuning
Vor dem Tuning stellt Chris den I-Regler auf 0.
Die Testflüge macht er im Angle-Mode, da in diesem Mode der Feedforward inaktiv ist.
P-D-Balance und Master-Regler
Der Copter wird einfach im Schwebeflug gehalten und dabei schnell hin und her gewackelt und vor/zurück. Chris macht eine Serie an Reglerpositionen auf einen Akku und bewegt den Regler im OSD. Zuerst wird die Balance eingestellt. Chris fliegt ein paar PD-Balance-Werte, z.B. 0.3, 0.4, 0.5, 0.75, 1.0, 1.25 und 1.5.
Nach der Testflugreihe zieht er das Log auf den PC und analysiert es mit der PID-Toolbox mit der Step-Response (Sprungantwort). Ziel ist es, schnell auf 1.0 zu kommen ohne über zu schwingen.
Sobald die optimale Balance gefunden wurde, wird der Master-Gain eingestellt. Z.B. die Werte 1.0, 1.25, 1.5, 1.75 und 2.0. Sollte der Weg des Reglers nicht reichen (gerade bei kleinen Coptern), gibt es einen Trick: Man stellt die individuellen Regler für P und D auf jeweils das Doppelte und den Master wieder auf die Mitte.
Man kann diese zwei Schritte auch wiederholen, um die Balance bei hohem Mastergain noch besser einzuregeln.
Feedforward einstellen
Ich habe mich mit FF noch nicht so anfreunden können, das liegt evtl. daran, dass meine Quads nicht viel darauf reagieren. Ziel ist es, den Delay zwischen Stick-Input („Setpoint“) und Gyro-Sensor auf 0 zu bekommen, der Quad „erahnt“ quasi die Knüppelbewegungen (reagiert auf die Knüppelbeschleunigung). Das klappt nicht immer 100%ig.
Chris stellt den I-Regler wieder auf 1.0. Man kann mit ein paar Probeflügen noch den I-Anteil feintunen, wie gut der Quad bei Punchouts die Lage hält und in der PID-Toolbox die Achsen an der 1.0 kleben ohne träger zu werden. Das Tuningfenster ist aber recht groß.
Chris testet die Reglerwerte 1.0, 1.25 und 1.5. Sollte sich die Verzögerung nicht verbessern oder die Kurven kräftig gestört sein, sollte man wieder eine Stufe zurück gehen.
Extras
Chris empfiehlt unbedingt „Dynamic-Idle“ zu aktivieren. Der Wert hängt von der Leerlaufdrehzahl ab. Für kleine 40mm stellt er 94 ein, 2″ 75, 3″ 50, etc. Dyn.Idle RPM = ~15000/Propeller-Durchmesser in Zoll. Der Wert in BF ist dann diese RPM geteilt durch 100.
Dann stellt man noch die Rates und die RC-Filterung (RC-Smoothing) noch nach eigenen Vorlieben ein.
Ich empfehle auch noch „crash_recovery=on“.
Und Arming-Angle 180°.
Sonstige Hinweise
Das Tuning hängt natürlich auch vom Akku ab, sollte man 2s,3s,4s,etc wild mischen, kann man die Presets nutzen und die automatische Umschaltung abhängig von der Zellenzahl „auto_profile_cell_count“. Auch Änderungen im Gewicht beeinflussen das Tuning (Gopro drauf oder nicht, großer Longrange-Lipo vs kleiner Lipo, etc)!
Mobula 7 2s update
Ja, ich hab ihn noch.
Auf BF 4.2.9, mit neuen Einstellungen:
Crash_recovery=ON
Dynamic RPM-Filter und Bluejay 0.2.0 48kHz (ich hab das Crazybee F4 V3 Pro mit BLHeli_S F_H_40)
So LOS-Schweben klingt er unglaublich smooth und stabil. Bei leichten absichtlich ausgeführten Crashs in z.B. den Wäscheständer stabilisiert er sich sofort. Toll!
Als nächstes werden die Lipo-Stecker auf BT2.0 oder GNB27 ausgetauscht. Die Motorstecker bleiben, die kleinen 0802 sind doch recht schnell durchgenudelt…
Licht-Fernsteuerung mit ESP32

Steuerung von bis zu 4 Slaves, mit je 10 LEDs. Das ist natürlich erweiterbar.
Die Slaves unterstützen WS2812 LEDs (an nodemcu:io15 oder esp32:io16) oder direkte Portpins (ESP32).
const byte lpin[NUMPIXELS] = {
27, // Licht 1
25,
32,
12,
4,
22,
21,
17,
23,
19 // Licht 10
};
Der Master hat einen Slave-Select-Input und 10 LED-Toggle-Tasten.
Die Funkverbindung läuft über ESP_NOW.
Langer Druck (ca. 3s) auf Select startet einen neuen Scan nach Slaves.
Master-Button-Pins:
const byte btnpins[NUMBUTTONS] = {
22, // slave select (long press for rescan slaves)
27, // licht 1-10 (long press for fw update on slave id 0-9)
14,
12,
23,
32,
33,
25,
26,
19,
18
};
Die Geräte haben alle Wifi-Webseiten. Sie heißen in der WLAN-Suche „LICHTSENDER“ bzw. „SlaveX:MAC-Adresse“ (X zählt hoch, ab 0). Das WIFI-Passwort ist „esp32licht“. Die IP-Adresse des Servers http://10.0.0.1.
Die Firmware lässt sich über Wifi updaten. Die Slaves kann man auch konfigurieren (ID, Lichtmodi, etc.)
Das hat mir mit dem Rückkanal sehr geholfen: https://www.esp32.com/viewtopic.php?t=13522
Onkyo K-611 Fehlersuche und Workaround
Mein niedliches 3-Kopf-Tapedeck K-611 spielt nicht. Bzw. sehr unregelmäßig geht es mal. Bevorzugt nach langer Standzeit.

Das Symptom: Der Kopf fährt 2 mal (oder öfters) auf die Play-Position hoch und reversiert gleich wieder.
Nachdem ich bestimmt 5-6 mal das Ding zerlegt hatte, Riemen getauscht, geputzt, neu geschmiert, was weiß ich – kam ich auf die grandiose Idee, mal die Mikroschalter anzuschauen:

Das sind die zwei Positionsschalter für den Kopfschlitten. Die 5V liegen nur während der Fahrt an.
Unten (gelb) sieht man den Schalter für „unten“ und oben (blau) für Play. Da man 2 Versuche sieht (und dazwischen die 5V ausgehen), wird klar, dass der blaue nie aktiviert.
Jetzt sind diese Schalter blöderweise ganz innen in der Mechanik hinter den Riemen und den Motoren vergraben…
Also: Ich simuliere den oberen Schalter einfach manuell!


Lösung: Einfach einen externen Schalter zum manuell betätigen anbringen. Ja, dirty hack, ich weiß… es klappt aber!
Das Innenleben:



Das Gerät ist erstaunlich komplex. 4 Motoren arbeiten da drin (Capstan, Spulen, Schlitten und Tür)! 3 Riemen (Capstan, Tür und Schlitten), den Schlittenriemen habe ich natürlich nach den Symptomen als erstes ausgetauscht. Den Türriemen habe ich zumindest „aufgefrischt“ (Kochen in Wasser).
Reparieren ist enorm aufwändig – viele Steckverbinder und Schrauben gilt es zu lösen und wieder zu montieren. Ich habe sogar mal die Capstan neu geschmiert.
Soweit spielt das Deck wieder gut. Operation geglückt!
Convert movies without recoding
ffmpeg -i input.mp4 -vcodec copy -acodec copy output.mkv