FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// officeMimeTypes are the document formats we route through LibreOffice
|
||||
// (soffice --headless --convert-to pdf) before OCR. Mirrors the Paperless-ngx
|
||||
// Gotenberg / Docspell LibreOffice conversion stage: the resulting PDF is then
|
||||
// fed to the normal pdftotext / pdftoppm+tesseract pipeline (ocrPDF), so both
|
||||
// text-layer PDFs and scanned-image content inside the office file are covered.
|
||||
var officeMimeTypes = map[string]bool{
|
||||
// Word / text processing
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.oasis.opendocument.text": true,
|
||||
"application/rtf": true,
|
||||
"text/rtf": true,
|
||||
// Spreadsheets
|
||||
"application/vnd.ms-excel": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
"application/vnd.oasis.opendocument.spreadsheet": true,
|
||||
// Presentations
|
||||
"application/vnd.ms-powerpoint": true,
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||
"application/vnd.oasis.opendocument.presentation": true,
|
||||
}
|
||||
|
||||
// isOfficeMime reports whether mimeType names an Office document format that
|
||||
// Extract routes through LibreOffice conversion.
|
||||
func isOfficeMime(mimeType string) bool {
|
||||
return officeMimeTypes[strings.ToLower(strings.TrimSpace(mimeType))]
|
||||
}
|
||||
|
||||
// officeConvertTimeout bounds a single LibreOffice conversion. Deliberately
|
||||
// more generous than the per-OCR-call timeout: a cold soffice start plus a
|
||||
// large spreadsheet can legitimately take longer than a tesseract page.
|
||||
func (e *Extractor) officeConvertTimeout() time.Duration {
|
||||
base := e.timeout()
|
||||
if base < 120*time.Second {
|
||||
return 120 * time.Second
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// officeToPDF converts an Office document at filePath to a temporary PDF via
|
||||
// LibreOffice headless, returning the PDF path and a cleanup func the caller
|
||||
// must defer. LibreOffice needs a private user-profile dir to run reliably and
|
||||
// concurrently (multiple soffice instances sharing the default profile clash),
|
||||
// so each conversion gets its own scratch dir under TmpDir.
|
||||
func (e *Extractor) officeToPDF(ctx context.Context, filePath string) (string, func(), error) {
|
||||
bin := e.sofficePath()
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice (%s) not found in PATH: %w", bin, err)
|
||||
}
|
||||
|
||||
workDir := filepath.Join(e.tmpDir(), "office-"+randomID())
|
||||
if err := os.MkdirAll(workDir, 0o700); err != nil {
|
||||
return "", nil, fmt.Errorf("ocr: create office convert dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(workDir) }
|
||||
|
||||
profileDir := filepath.Join(workDir, "profile")
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, e.officeConvertTimeout())
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"--headless", "--norestore", "--nologo", "--nolockcheck",
|
||||
"-env:UserInstallation=file://" + profileDir,
|
||||
"--convert-to", "pdf", "--outdir", workDir, filePath,
|
||||
}
|
||||
cmd := exec.CommandContext(cctx, bin, args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
cleanup()
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice conversion timed out after %s", e.officeConvertTimeout())
|
||||
}
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice conversion failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
// soffice names the output <input-basename>.pdf in --outdir. Prefer that
|
||||
// exact name, but fall back to the first *.pdf in the dir in case the base
|
||||
// name was sanitized.
|
||||
base := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
pdfPath := filepath.Join(workDir, base+".pdf")
|
||||
if _, err := os.Stat(pdfPath); err != nil {
|
||||
matches, _ := filepath.Glob(filepath.Join(workDir, "*.pdf"))
|
||||
if len(matches) == 0 {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("ocr: libreoffice produced no pdf for %s", filepath.Base(filePath))
|
||||
}
|
||||
pdfPath = matches[0]
|
||||
}
|
||||
e.log(slog.LevelInfo, "office document converted to pdf",
|
||||
"src", filepath.Base(filePath), "pdf", filepath.Base(pdfPath))
|
||||
return pdfPath, cleanup, nil
|
||||
}
|
||||
|
||||
// extractEML parses an .eml file and returns its human-readable text: a short
|
||||
// header block (Date/From/To/Cc/Subject, MIME-word-decoded) followed by the
|
||||
// concatenated text of every text/plain and (tag-stripped) text/html body part.
|
||||
// Binary attachments are ignored. Best-effort throughout — a malformed message
|
||||
// yields whatever could be parsed rather than an error, so an e-mail is never
|
||||
// silently dropped from full-text search.
|
||||
func (e *Extractor) extractEML(filePath string) (*Result, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocr: read eml: %w", err)
|
||||
}
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocr: parse eml: %w", err)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
dec := new(mime.WordDecoder)
|
||||
for _, h := range []string{"Date", "From", "To", "Cc", "Subject"} {
|
||||
v := msg.Header.Get(h)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if d, derr := dec.DecodeHeader(v); derr == nil {
|
||||
v = d
|
||||
}
|
||||
sb.WriteString(h)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(v)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
body := mailPartText(msg.Header.Get("Content-Type"), msg.Header.Get("Content-Transfer-Encoding"), msg.Body)
|
||||
sb.WriteString(body)
|
||||
|
||||
return &Result{Text: strings.TrimSpace(sb.String())}, nil
|
||||
}
|
||||
|
||||
// mailPartText recursively extracts readable text from a MIME part. multipart/*
|
||||
// containers are walked; text/plain is decoded verbatim, text/html is decoded
|
||||
// and tag-stripped; everything else (attachments, images) is skipped.
|
||||
func mailPartText(contentType, cte string, body io.Reader) string {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType == "" {
|
||||
// No/invalid Content-Type: assume text/plain.
|
||||
return decodeMailBody(body, cte)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return ""
|
||||
}
|
||||
mr := multipart.NewReader(body, boundary)
|
||||
var parts []string
|
||||
for {
|
||||
p, perr := mr.NextPart()
|
||||
if perr != nil {
|
||||
break
|
||||
}
|
||||
pt := mailPartText(p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), p)
|
||||
p.Close()
|
||||
if strings.TrimSpace(pt) != "" {
|
||||
parts = append(parts, pt)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "text/plain"):
|
||||
return decodeMailBody(body, cte)
|
||||
case strings.HasPrefix(mediaType, "text/html"):
|
||||
return stripHTML(decodeMailBody(body, cte))
|
||||
default:
|
||||
return "" // attachment / binary part
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailBody reads a leaf MIME part, undoing base64 / quoted-printable
|
||||
// transfer encoding.
|
||||
func decodeMailBody(r io.Reader, cte string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(cte)) {
|
||||
case "base64":
|
||||
r = base64.NewDecoder(base64.StdEncoding, r)
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return string(b) // return whatever decoded before the error
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var (
|
||||
htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>`)
|
||||
anyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
wsRe = regexp.MustCompile(`[ \t\f\v]+`)
|
||||
blankRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// stripHTML reduces an HTML body to readable plain text: drops script/style
|
||||
// blocks and all tags, unescapes entities, and collapses runaway whitespace.
|
||||
func stripHTML(s string) string {
|
||||
s = htmlTagRe.ReplaceAllString(s, " ")
|
||||
s = anyTagRe.ReplaceAllString(s, " ")
|
||||
s = html.UnescapeString(s)
|
||||
s = wsRe.ReplaceAllString(s, " ")
|
||||
s = blankRe.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Word-level bounding boxes for OCR text-highlight/overlay (Phase 1 —
|
||||
// datengrundlage only, see project memory
|
||||
// project_ocr_textmarkierung_overlay.md). This file adds:
|
||||
//
|
||||
// - WordBox / TSV extraction (tesseract's `tsv` output mode)
|
||||
// - a small geometry-transform mechanism to map word boxes from the
|
||||
// coordinate space of the final, fully-preprocessed image tesseract
|
||||
// actually recognized text on, back into the coordinate space of the
|
||||
// file the frontend actually displays to the user.
|
||||
//
|
||||
// Koordinatenraum (why this file exists at all): runTesseract's
|
||||
// preprocessing pipeline (clampImageSize -> deskewImage -> normalizeContrast
|
||||
// -> rotateForOSD, see ocr.go) can resize and rotate the image before
|
||||
// tesseract ever sees it. The frontend, however, always renders the
|
||||
// untouched original upload (internal/api/document_handlers.go
|
||||
// handleGetDocumentFile serves doc.StoragePath byte-for-byte; verified
|
||||
// 2026-07-30 — no transformed copy is ever persisted or served). Word boxes
|
||||
// from tesseract are therefore in the WRONG coordinate space for direct use
|
||||
// against the displayed image unless mapped back.
|
||||
//
|
||||
// Full forward order in runTesseract (each step optional):
|
||||
//
|
||||
// clampImageSize -> deskewImage | deskewImageHough -> normalizeContrast
|
||||
// -> rotateForOSD -> binarizeImage
|
||||
//
|
||||
// and, for image uploads only, one final forward step applied in ocrImage
|
||||
// AFTER the inversion above: the file's EXIF Orientation (see exif.go), which
|
||||
// moves the boxes from raw-pixel space into the space the browser actually
|
||||
// renders. Inversion happens strictly last-forward-step-first
|
||||
// (mapWordsToOriginal iterates the slice backwards), so any combination —
|
||||
// e.g. clamp + hough-deskew + OSD 90 degrees + EXIF 6 — composes correctly:
|
||||
// the geometric chain is undone in reverse, then EXIF is applied once on top.
|
||||
//
|
||||
// What is handled exactly vs. approximately:
|
||||
// - clampImageSize: pure uniform scale -> inverted exactly (simple ratio).
|
||||
// - normalizeContrast / binarizeImage: no geometry change; still measured
|
||||
// (geomChain.recordScale) rather than assumed, and dropped as identity.
|
||||
// - rotateForOSD: our own rotate90CW, always an exact multiple of 90
|
||||
// degrees -> inverted with pixel-exact integer math (mirrors the forward
|
||||
// loop in rotateImageFile step for step, no trig/rounding involved).
|
||||
// - deskewImage (ImageMagick `-deskew`, arbitrary small angle + canvas
|
||||
// resize to bound the rotated image): inverted via the standard
|
||||
// rotate-about-center formula using the angle ImageMagick reports via
|
||||
// `-print "%[deskew:angle]"` plus before/after pixel dimensions. This is
|
||||
// geometrically the correct construction for a generic "rotate and
|
||||
// expand canvas" operation. Sign convention reviewed 2026-07-30 against
|
||||
// ImageMagick's source behaviour: DeskewImage derives the `deskew:angle`
|
||||
// artifact from the same `degrees` it feeds into the affine matrix
|
||||
// [[cos,-sin],[sin,cos]], and AffineTransformImage expands the canvas
|
||||
// symmetrically about the centre (auto-crop off by default) — so the
|
||||
// centre-to-centre inverse with rad = -angleDeg below is the exact
|
||||
// transpose. Still not validated against a real deskewed sample's pixel
|
||||
// output, so treat it as reviewed-but-not-field-verified.
|
||||
// Per project memory (two prior deskew-angle tuning attempts were tested
|
||||
// against the doc id 4-9 corpus and rejected — see
|
||||
// project_deskew_border_trick_tested_negative.md and
|
||||
// project_deskew_disable_for_photos_tested_negative.md), do NOT blindly
|
||||
// adjust this formula's sign/rounding by trial and error; instead verify
|
||||
// against a real deskewed sample (overlay the mapped word boxes on the
|
||||
// original image) before touching it, and record the result either way.
|
||||
// - deskewImageHough: the angle is detected by hough_deskew.py but APPLIED
|
||||
// by `convert -rotate <angle>`, whose sign convention is documented and
|
||||
// unambiguous (positive = clockwise). The inverse below therefore IS
|
||||
// verified for this path — the unverified sign caveat above applies only
|
||||
// to ImageMagick's own `-deskew`/%[deskew:angle] pair.
|
||||
// - Steps whose geometry cannot be measured (image.DecodeConfig only knows
|
||||
// the formats this package imports, i.e. JPEG and PNG — a TIFF/BMP/WebP
|
||||
// upload fails every measurement while ImageMagick still processes it)
|
||||
// invalidate the whole chain via geomChain, and the document then gets NO
|
||||
// word boxes. Silently skipping such a step used to leave the remaining
|
||||
// transforms mapping into a coordinate space that no longer existed.
|
||||
//
|
||||
// PDF scope note: for the pdftoppm raster-fallback OCR path, WordBox
|
||||
// coordinates are mapped back to the *rasterized page PNG's* pixel space
|
||||
// (post-preprocessing -> pre-preprocessing raster), not further back into
|
||||
// PDF point/MediaBox coordinate space. The frontend currently renders PDFs
|
||||
// via the browser's native PDF viewer (iframe over the original file), which
|
||||
// uses PDF page-coordinate space, not raster pixels — mapping raster pixels
|
||||
// into that space is a straightforward additional scale step (raster DPI vs.
|
||||
// MediaBox size, both knowable via pdftoppm's -r 300 and `pdfinfo`) but is
|
||||
// left for whoever builds the overlay UI in a later phase, since it depends
|
||||
// on how that phase chooses to render PDF pages (canvas render at a chosen
|
||||
// DPI vs. native iframe).
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"image"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// WordBox is a single OCR-recognized word with its bounding box, already
|
||||
// mapped (best-effort — see package doc comment above) into the coordinate
|
||||
// space of the file the frontend actually displays for the document this
|
||||
// word was found in.
|
||||
type WordBox struct {
|
||||
Text string
|
||||
Left int
|
||||
Top int
|
||||
Width int
|
||||
Height int
|
||||
Confidence float64
|
||||
// Line, Block, Par come straight from tesseract's TSV line_num/block_num/
|
||||
// par_num columns, useful for later grouping words into lines/paragraphs
|
||||
// (e.g. for the eventual highlight-overlay UI) without re-deriving that
|
||||
// from raw positions.
|
||||
Line int
|
||||
Block int
|
||||
Par int
|
||||
// Page is the 1-based PDF page number this word was found on. Always 1
|
||||
// for image uploads (a single "page"; there is no page 0 in output).
|
||||
Page int
|
||||
}
|
||||
|
||||
// geomTransform describes one preprocessing step's effect on image geometry,
|
||||
// used to invert tesseract's word bounding boxes back towards the originally
|
||||
// displayed file. See the package doc comment for what is exact vs.
|
||||
// best-effort here.
|
||||
type geomTransform struct {
|
||||
oldW, oldH int
|
||||
newW, newH int
|
||||
// angleDeg is the clockwise rotation applied around the image center, in
|
||||
// degrees. Zero for a pure resize/no-op step.
|
||||
angleDeg float64
|
||||
// exact90 marks a rotation known to be an exact multiple of 90 degrees,
|
||||
// produced by our own rotate90CW (rotateForOSD) — inverted with
|
||||
// pixel-exact integer math rather than the trig formula used for
|
||||
// deskew's arbitrary angle.
|
||||
exact90 bool
|
||||
}
|
||||
|
||||
// invert maps a point (x, y) from the "new" (post-step) image's pixel space
|
||||
// back into the "old" (pre-step) image's pixel space.
|
||||
func (t geomTransform) invert(x, y float64) (float64, float64) {
|
||||
if t.angleDeg == 0 {
|
||||
if t.newW == 0 || t.newH == 0 {
|
||||
return x, y
|
||||
}
|
||||
scaleX := float64(t.oldW) / float64(t.newW)
|
||||
scaleY := float64(t.oldH) / float64(t.newH)
|
||||
return x * scaleX, y * scaleY
|
||||
}
|
||||
|
||||
if t.exact90 {
|
||||
steps := (int(math.Round(t.angleDeg)) / 90) % 4
|
||||
if steps < 0 {
|
||||
steps += 4
|
||||
}
|
||||
curW, curH := t.newW, t.newH
|
||||
cx, cy := x, y
|
||||
for i := 0; i < steps; i++ {
|
||||
// Forward step (rotateImageFile/rotate90CW) was, on pixel
|
||||
// INDICES: src(w,h) -> dst(h,w), src(x,y) -> dst(h-1-y, x).
|
||||
// mapWordsToOriginal feeds box EDGE coordinates (left..left+width,
|
||||
// i.e. a continuous [0,w] range, not indices [0,w-1]), so the
|
||||
// continuous form of the same rotation is used here:
|
||||
// dst(x,y) = (h - y, x) => src = (cy, curW - cx)
|
||||
// (Identical convention to applyEXIFOrientation in exif.go; using
|
||||
// the index form on edge coordinates would shift every box by one
|
||||
// pixel per rotation step.)
|
||||
nx := cy
|
||||
ny := float64(curW) - cx
|
||||
curW, curH = curH, curW
|
||||
cx, cy = nx, ny
|
||||
}
|
||||
return cx, cy
|
||||
}
|
||||
|
||||
// General case (ImageMagick -deskew): rotation about the image center
|
||||
// with the canvas expanded to bound the rotated image. Sign convention:
|
||||
// positive angleDeg == clockwise (ImageMagick `-rotate`), so the inverse
|
||||
// rotates by -angleDeg about the new centre and re-centres on the old
|
||||
// canvas. See package doc comment for how far this is verified per path
|
||||
// (hough: yes; ImageMagick's own -deskew: source-reviewed only).
|
||||
rad := -t.angleDeg * math.Pi / 180
|
||||
cxNew, cyNew := float64(t.newW)/2, float64(t.newH)/2
|
||||
cxOld, cyOld := float64(t.oldW)/2, float64(t.oldH)/2
|
||||
dx, dy := x-cxNew, y-cyNew
|
||||
cos, sin := math.Cos(rad), math.Sin(rad)
|
||||
rx := dx*cos - dy*sin
|
||||
ry := dx*sin + dy*cos
|
||||
return rx + cxOld, ry + cyOld
|
||||
}
|
||||
|
||||
// isIdentity reports whether this step changed no geometry at all (same
|
||||
// dimensions, no rotation) and can therefore be dropped from the chain.
|
||||
func (t geomTransform) isIdentity() bool {
|
||||
return t.angleDeg == 0 && t.oldW == t.newW && t.oldH == t.newH
|
||||
}
|
||||
|
||||
// mapWordsToOriginal applies transforms in reverse (last-applied-preprocessing-
|
||||
// step-first) order, mutating words in place to convert their bounding boxes
|
||||
// from final-tesseract-image space into the coordinate space of the file
|
||||
// before any of these transforms ran.
|
||||
//
|
||||
// ALL FOUR corners are inverted, not just top-left/bottom-right. That matters
|
||||
// as soon as a non-90-degree rotation (deskew) is in the chain: under a
|
||||
// rotation the two opposite corners alone no longer span the rotated
|
||||
// rectangle's axis-aligned bounding box — for a typical 2-3 degree deskew the
|
||||
// resulting box is systematically too narrow/short and offset, and at angles
|
||||
// approaching 45 degrees it collapses towards zero size. The result here is
|
||||
// the true axis-aligned bounding box of the back-rotated word quad, which is
|
||||
// what the frontend overlay draws.
|
||||
func mapWordsToOriginal(words []WordBox, transforms []geomTransform) {
|
||||
if len(transforms) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range words {
|
||||
l, t := float64(words[i].Left), float64(words[i].Top)
|
||||
r, b := float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height)
|
||||
corners := [4][2]float64{{l, t}, {r, t}, {r, b}, {l, b}}
|
||||
for c := range corners {
|
||||
x, y := corners[c][0], corners[c][1]
|
||||
for j := len(transforms) - 1; j >= 0; j-- {
|
||||
x, y = transforms[j].invert(x, y)
|
||||
}
|
||||
corners[c][0], corners[c][1] = x, y
|
||||
}
|
||||
minX, maxX := corners[0][0], corners[0][0]
|
||||
minY, maxY := corners[0][1], corners[0][1]
|
||||
for c := 1; c < 4; c++ {
|
||||
minX = math.Min(minX, corners[c][0])
|
||||
maxX = math.Max(maxX, corners[c][0])
|
||||
minY = math.Min(minY, corners[c][1])
|
||||
maxY = math.Max(maxY, corners[c][1])
|
||||
}
|
||||
words[i].Left = int(math.Round(minX))
|
||||
words[i].Top = int(math.Round(minY))
|
||||
words[i].Width = int(math.Round(maxX - minX))
|
||||
words[i].Height = int(math.Round(maxY - minY))
|
||||
}
|
||||
}
|
||||
|
||||
// geomChain collects the geometry-changing preprocessing steps of a single
|
||||
// runTesseract pass, so word boxes can be inverted back into the source
|
||||
// image's coordinate space afterwards.
|
||||
//
|
||||
// The important property it enforces (this was a real, silent bug before):
|
||||
// a preprocessing step that DID change geometry but whose geometry could not
|
||||
// be measured must invalidate the whole chain, not just be skipped. Skipping
|
||||
// it leaves the remaining transforms mapping into a coordinate space that no
|
||||
// longer exists, and the frontend then draws a confidently wrong overlay.
|
||||
// The realistic trigger is an upload format image.DecodeConfig cannot read:
|
||||
// this package only registers image/jpeg and image/png, so TIFF/BMP/WebP/GIF
|
||||
// uploads (all accepted as image/*) fail every decodeImageDims call while
|
||||
// ImageMagick happily processes them. Rather than misplace boxes we return
|
||||
// none for those documents.
|
||||
type geomChain struct {
|
||||
steps []geomTransform
|
||||
broken bool
|
||||
log func(level slog.Level, msg string, args ...any)
|
||||
}
|
||||
|
||||
// recordScale books a step that may only scale the image uniformly
|
||||
// (clampImageSize) or must not change geometry at all (normalizeContrast,
|
||||
// binarizeImage). Identity steps are dropped.
|
||||
func (c *geomChain) recordScale(step, oldPath, newPath string) {
|
||||
t, ok := buildScaleTransform(oldPath, newPath)
|
||||
if !ok {
|
||||
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
|
||||
return
|
||||
}
|
||||
if t.isIdentity() {
|
||||
return
|
||||
}
|
||||
c.steps = append(c.steps, t)
|
||||
}
|
||||
|
||||
// recordRotation books a rotation step (deskewImage/deskewImageHough/
|
||||
// rotateForOSD). A reported angle of 0 combined with changed dimensions means
|
||||
// the angle was lost (e.g. an ImageMagick build not populating
|
||||
// %[deskew:angle]) while a rotation really was applied — unrecoverable, so
|
||||
// the chain is invalidated instead of silently mapping with angle 0.
|
||||
func (c *geomChain) recordRotation(step, oldPath, newPath string, angleDeg float64, exact90 bool) {
|
||||
t, ok := buildRotationTransform(oldPath, newPath, angleDeg, exact90)
|
||||
if !ok {
|
||||
c.fail(step, "image dimensions unreadable (unsupported format for image.DecodeConfig?)")
|
||||
return
|
||||
}
|
||||
if angleDeg == 0 && (t.oldW != t.newW || t.oldH != t.newH) {
|
||||
c.fail(step, "rotation applied but angle unknown (0) — cannot invert")
|
||||
return
|
||||
}
|
||||
if t.isIdentity() {
|
||||
return
|
||||
}
|
||||
c.steps = append(c.steps, t)
|
||||
}
|
||||
|
||||
func (c *geomChain) fail(step, reason string) {
|
||||
c.broken = true
|
||||
if c.log != nil {
|
||||
c.log(slog.LevelWarn, "ocr word boxes disabled: preprocessing geometry not invertible",
|
||||
"step", step, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// transforms returns the collected chain; ok is false when any step could not
|
||||
// be recorded reliably, in which case callers must not emit word boxes at all.
|
||||
func (c *geomChain) transforms() ([]geomTransform, bool) {
|
||||
if c.broken {
|
||||
return nil, false
|
||||
}
|
||||
return c.steps, true
|
||||
}
|
||||
|
||||
// decodeImageDims returns the pixel width/height of the image at path
|
||||
// without decoding full pixel data (image.DecodeConfig only reads the
|
||||
// header).
|
||||
func decodeImageDims(path string) (w, h int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// buildScaleTransform records a pure-resize geometry step (clampImageSize)
|
||||
// by decoding both images' dimensions. ok is false if either image's
|
||||
// dimensions cannot be read, in which case the caller should skip recording
|
||||
// a transform (best-effort, same tolerance as the rest of this package).
|
||||
func buildScaleTransform(oldPath, newPath string) (geomTransform, bool) {
|
||||
oldW, oldH, err := decodeImageDims(oldPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
newW, newH, err := decodeImageDims(newPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
return geomTransform{oldW: oldW, oldH: oldH, newW: newW, newH: newH}, true
|
||||
}
|
||||
|
||||
// buildRotationTransform records a rotation geometry step (deskewImage or
|
||||
// rotateForOSD) by decoding both images' dimensions plus the rotation angle
|
||||
// applied. exact90 distinguishes rotateForOSD's pixel-exact 90-degree
|
||||
// rotations from deskewImage's arbitrary-angle, best-effort inverse.
|
||||
func buildRotationTransform(oldPath, newPath string, angleDeg float64, exact90 bool) (geomTransform, bool) {
|
||||
oldW, oldH, err := decodeImageDims(oldPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
newW, newH, err := decodeImageDims(newPath)
|
||||
if err != nil {
|
||||
return geomTransform{}, false
|
||||
}
|
||||
return geomTransform{
|
||||
oldW: oldW, oldH: oldH,
|
||||
newW: newW, newH: newH,
|
||||
angleDeg: angleDeg,
|
||||
exact90: exact90,
|
||||
}, true
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package ocr
|
||||
|
||||
// EXIF-Orientierung für den OCR-Wortbox-Koordinatenraum.
|
||||
//
|
||||
// Warum diese Datei existiert (Root Cause des Overlay-Versatzes, 2026-07-30):
|
||||
// Die Vorverarbeitungskette in runTesseract arbeitet ausschließlich auf ROHEN
|
||||
// Pixeln — weder tesseract noch ImageMagick `convert` wenden das EXIF-Tag
|
||||
// `Orientation` von selbst an (dafür bräuchte es explizit `-auto-orient`).
|
||||
// mapWordsToOriginal rechnet die Wortboxen folglich in den ROH-Pixelraum der
|
||||
// gespeicherten Datei zurück.
|
||||
//
|
||||
// Der Browser tut aber genau das Gegenteil: seit der Vereinheitlichung von
|
||||
// `image-orientation: from-image` als Default (Chrome 81+, Firefox 26+,
|
||||
// Safari 13.1+) rendert er ein <img> IMMER EXIF-orientiert und meldet auch
|
||||
// naturalWidth/naturalHeight bereits gedreht. Bei einem Handyfoto mit
|
||||
// Orientation 6/8 (Hochkant aufgenommen, Sensor liefert Querformat-Pixel)
|
||||
// zeigt das Frontend also ein 3000x4000-Bild, während jede Wortbox in
|
||||
// 4000x3000-Rohkoordinaten vorliegt: das Overlay ist um 90 Grad verdreht und
|
||||
// liegt zum Teil komplett außerhalb des Bildes. Genau das ist das gemeldete
|
||||
// "passt nicht mit den OCR-Feldern" — kein Subpixel-/Deskew-Problem, sondern
|
||||
// ein kompletter Raumwechsel.
|
||||
//
|
||||
// Lösung: nach der Rücktransformation in den Rohraum wird hier EINMAL die
|
||||
// EXIF-Orientierung vorwärts angewandt, damit die gespeicherten Koordinaten im
|
||||
// tatsächlich DARGESTELLTEN Raum liegen (das ist auch die dokumentierte
|
||||
// Semantik der ocr_words-Spalten und der API — "Koordinatenraum der
|
||||
// angezeigten Datei"). Orientation 1 (bzw. kein EXIF, PNG, PDF-Raster) ist ein
|
||||
// No-Op, betrifft also nur genau die Fotos, bei denen der Browser dreht.
|
||||
//
|
||||
// Der EXIF-Parser ist bewusst minimal und dependency-frei (nur stdlib): er
|
||||
// sucht den APP1/"Exif\0\0"-Marker, liest den TIFF-Header und die IFD0-Einträge
|
||||
// und gibt Tag 0x0112 zurück. Alles andere (XMP, MakerNotes, Thumbnails) wird
|
||||
// nicht angefasst.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
// errNoEXIFOrientation signalisiert "kein verwertbares Orientation-Tag" —
|
||||
// Aufrufer behandeln das wie Orientation 1.
|
||||
var errNoEXIFOrientation = errors.New("ocr: no exif orientation")
|
||||
|
||||
// maxEXIFScan begrenzt, wie weit wir im JPEG nach dem APP1-Segment suchen.
|
||||
// EXIF steht per Spezifikation direkt hinter SOI; die Grenze verhindert nur,
|
||||
// dass eine kaputte Datei uns durch das ganze Bild laufen lässt.
|
||||
const maxEXIFScan = 1 << 20 // 1 MiB
|
||||
|
||||
// jpegEXIFOrientation liefert den Wert des EXIF-Tags Orientation (1..8) der
|
||||
// Datei an path. Für Nicht-JPEGs, JPEGs ohne EXIF, unlesbare oder unplausible
|
||||
// Werte wird 1 (= keine Drehung) zurückgegeben; ein Fehler wird nur zur
|
||||
// optionalen Diagnose mitgegeben und ist für Aufrufer nicht fatal.
|
||||
func jpegEXIFOrientation(path string) (int, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var soi [2]byte
|
||||
if _, err := io.ReadFull(f, soi[:]); err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if soi[0] != 0xFF || soi[1] != 0xD8 { // kein JPEG (PNG/TIFF/…): kein EXIF-Handling
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
scanned := 0
|
||||
var hdr [4]byte
|
||||
for scanned < maxEXIFScan {
|
||||
// Marker suchen: beliebig viele 0xFF-Füllbytes, dann der Markercode.
|
||||
var b [1]byte
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned++
|
||||
if b[0] != 0xFF {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
if _, err := io.ReadFull(f, b[:]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned++
|
||||
if b[0] != 0xFF {
|
||||
break
|
||||
}
|
||||
}
|
||||
marker := b[0]
|
||||
switch {
|
||||
case marker == 0xDA || marker == 0xD9:
|
||||
// SOS (Bilddaten) bzw. EOI (Dateiende) erreicht: ab hier kann kein
|
||||
// APP1/EXIF-Segment mehr kommen. Muss VOR der Prüfung auf
|
||||
// längenlose Marker stehen — 0xD9 fällt sonst in den
|
||||
// RST/D0..D7-Bereich und wir würden durch die Bilddaten weiterlaufen.
|
||||
return 1, errNoEXIFOrientation
|
||||
case marker == 0x00 || marker == 0xFF:
|
||||
continue // Byte-Stuffing/Füllbyte, kein echter Marker
|
||||
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
|
||||
continue // SOI/TEM/RSTn: längenlose Marker
|
||||
}
|
||||
if _, err := io.ReadFull(f, hdr[:2]); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
segLen := int(binary.BigEndian.Uint16(hdr[:2]))
|
||||
if segLen < 2 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
payload := make([]byte, segLen-2)
|
||||
if _, err := io.ReadFull(f, payload); err != nil {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
scanned += segLen
|
||||
if marker != 0xE1 || len(payload) < 6 || string(payload[:6]) != "Exif\x00\x00" {
|
||||
continue
|
||||
}
|
||||
return orientationFromTIFF(payload[6:])
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
// orientationFromTIFF liest Tag 0x0112 aus dem IFD0 eines TIFF-Headers (der
|
||||
// Nutzlast eines EXIF-APP1-Segments ohne "Exif\0\0"-Präfix).
|
||||
func orientationFromTIFF(tiff []byte) (int, error) {
|
||||
if len(tiff) < 8 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch {
|
||||
case tiff[0] == 'I' && tiff[1] == 'I':
|
||||
bo = binary.LittleEndian
|
||||
case tiff[0] == 'M' && tiff[1] == 'M':
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 42 {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
offset := int(bo.Uint32(tiff[4:8]))
|
||||
if offset < 8 || offset+2 > len(tiff) {
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
count := int(bo.Uint16(tiff[offset : offset+2]))
|
||||
entry := offset + 2
|
||||
for i := 0; i < count; i++ {
|
||||
if entry+12 > len(tiff) {
|
||||
break
|
||||
}
|
||||
tag := bo.Uint16(tiff[entry : entry+2])
|
||||
typ := bo.Uint16(tiff[entry+2 : entry+4])
|
||||
if tag == 0x0112 && typ == 3 /* SHORT */ {
|
||||
v := int(bo.Uint16(tiff[entry+8 : entry+10]))
|
||||
if v >= 1 && v <= 8 {
|
||||
return v, nil
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
entry += 12
|
||||
}
|
||||
return 1, errNoEXIFOrientation
|
||||
}
|
||||
|
||||
// applyEXIFOrientation überführt Wortboxen aus dem ROH-Pixelraum eines Bildes
|
||||
// (Breite rawW, Höhe rawH) in den vom Browser DARGESTELLTEN Raum, indem die
|
||||
// EXIF-Orientierung orientation (1..8) vorwärts angewandt wird. orientation 1
|
||||
// sowie ungültige Werte/Dimensionen sind ein No-Op.
|
||||
//
|
||||
// Die acht EXIF-Fälle entsprechen den üblichen Definitionen (2/4/5/7 enthalten
|
||||
// eine Spiegelung; sie kommen bei Kameras praktisch nicht vor, werden aber der
|
||||
// Vollständigkeit halber korrekt behandelt, damit hier nie stillschweigend ein
|
||||
// falscher Raum entsteht):
|
||||
//
|
||||
// 1 (x, y) 2 (W-x, y) 3 (W-x, H-y) 4 (x, H-y)
|
||||
// 5 (y, x) 6 (H-y, x) 7 (H-y, W-x) 8 (y, W-x)
|
||||
//
|
||||
// Bei 5..8 tauschen Breite und Höhe die Rollen — genau der Fall, in dem das
|
||||
// Overlay ohne diese Korrektur komplett neben dem Bild landet.
|
||||
func applyEXIFOrientation(words []WordBox, orientation, rawW, rawH int) {
|
||||
if orientation <= 1 || orientation > 8 || rawW <= 0 || rawH <= 0 {
|
||||
return
|
||||
}
|
||||
w, h := float64(rawW), float64(rawH)
|
||||
mapPoint := func(x, y float64) (float64, float64) {
|
||||
switch orientation {
|
||||
case 2:
|
||||
return w - x, y
|
||||
case 3:
|
||||
return w - x, h - y
|
||||
case 4:
|
||||
return x, h - y
|
||||
case 5:
|
||||
return y, x
|
||||
case 6:
|
||||
return h - y, x
|
||||
case 7:
|
||||
return h - y, w - x
|
||||
case 8:
|
||||
return y, w - x
|
||||
default:
|
||||
return x, y
|
||||
}
|
||||
}
|
||||
for i := range words {
|
||||
x0, y0 := mapPoint(float64(words[i].Left), float64(words[i].Top))
|
||||
x1, y1 := mapPoint(float64(words[i].Left+words[i].Width), float64(words[i].Top+words[i].Height))
|
||||
if x0 > x1 {
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
if y0 > y1 {
|
||||
y0, y1 = y1, y0
|
||||
}
|
||||
// math.Round statt int(v+0.5): Wortboxen können nach der
|
||||
// Rücktransformation aus einem Deskew-Schritt knapp negative
|
||||
// Randkoordinaten haben, dort rundet int(v+0.5) in die falsche Richtung.
|
||||
words[i].Left = int(math.Round(x0))
|
||||
words[i].Top = int(math.Round(y0))
|
||||
words[i].Width = int(math.Round(x1 - x0))
|
||||
words[i].Height = int(math.Round(y1 - y0))
|
||||
}
|
||||
}
|
||||
+1060
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hough_deskew.py — fine-skew angle detector for the archivdms OCR pipeline.
|
||||
|
||||
Sidecar script for internal/ocr/ocr.go's `hough` deskew method
|
||||
(config.OCRConfig.DeskewMethod == "hough"), an ALTERNATIVE to the default
|
||||
ImageMagick `-deskew` peak/valley text-line projection analysis
|
||||
(deskewImage() in ocr.go). ImageMagick's approach needs surrounding
|
||||
background/margin to find the page's background rows/columns and fails on
|
||||
tightly-cropped phone photos of receipts (no margin context) — see
|
||||
project_deskew_disable_for_photos_tested_negative and
|
||||
project_deskew_border_trick_tested_negative in agent memory for two
|
||||
previously-tried and rejected workarounds. This script separates ANGLE
|
||||
DETECTION (via OpenCV, this file) from angle APPLICATION (plain `convert
|
||||
-rotate <deg>` in ocr.go) per the recommendation that produced this rewrite.
|
||||
|
||||
Usage:
|
||||
python3 hough_deskew.py <image-path>
|
||||
|
||||
Behavior:
|
||||
- Reads the image with OpenCV, grayscale + Otsu threshold.
|
||||
- Finds the largest contour by area and takes cv2.minAreaRect() of it.
|
||||
This is deliberately NOT text-line-projection-based (that is exactly
|
||||
what ImageMagick already does and what fails on cropped photos) —
|
||||
minAreaRect degrades gracefully to "the boundary of whatever content is
|
||||
in frame" even when that content fills the whole image, which is
|
||||
normally the case for a tightly-cropped phone photo.
|
||||
- Falls back to cv2.HoughLinesP() long-line-angle voting if no usable
|
||||
contour is found (e.g. near-blank background, no single dominant
|
||||
shape) — takes the median angle of detected line segments within
|
||||
+/-45 degrees of horizontal.
|
||||
- Prints exactly one float (the skew angle in degrees, ImageMagick
|
||||
`-rotate` sign convention: positive = clockwise) to stdout and exits 0
|
||||
on success.
|
||||
- On any failure (bad path, unreadable image, no contours/lines found),
|
||||
prints nothing to stdout, writes a one-line reason to stderr, and
|
||||
exits non-zero. ocr.go's houghDeskewAngle treats this as "angle 0,
|
||||
keep going" — never a fatal OCR error.
|
||||
|
||||
Dependencies: opencv-python (or the Debian python3-opencv apt package, which
|
||||
pulls in numpy as a transitive dependency) — no other third-party packages.
|
||||
Deliberately not using the `deskew` PyPI package: it wraps a very similar
|
||||
Radon/Hough approach but pulls in scikit-image, a much heavier dependency
|
||||
tree, for no accuracy benefit found in testing.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
except ImportError as exc: # pragma: no cover - environment/dependency issue
|
||||
print(f"hough_deskew: missing dependency: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _angle_from_min_area_rect(gray: "np.ndarray"):
|
||||
"""Return a skew angle in degrees via Otsu threshold + largest contour's
|
||||
minAreaRect, or None if no usable contour was found."""
|
||||
_, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
|
||||
largest = max(contours, key=cv2.contourArea)
|
||||
# Ignore contours covering too little of the frame — noise/artifacts, not
|
||||
# the document itself.
|
||||
img_area = gray.shape[0] * gray.shape[1]
|
||||
if cv2.contourArea(largest) < 0.05 * img_area:
|
||||
return None
|
||||
|
||||
rect = cv2.minAreaRect(largest)
|
||||
angle = rect[2] # OpenCV: angle in (-90, 0] for cv2.minAreaRect
|
||||
|
||||
# Normalize to the smallest rotation that would make the rect's long side
|
||||
# horizontal (matches ImageMagick -deskew / -rotate's small-angle
|
||||
# convention rather than cv2's raw (-90, 0] range).
|
||||
w, h = rect[1]
|
||||
if w < h:
|
||||
angle = angle + 90
|
||||
if angle > 45:
|
||||
angle -= 90
|
||||
elif angle < -45:
|
||||
angle += 90
|
||||
return angle
|
||||
|
||||
|
||||
def _angle_from_hough_lines(gray: "np.ndarray"):
|
||||
"""Fallback: median angle of long line segments detected via
|
||||
HoughLinesP, restricted to +/-45 degrees of horizontal. Returns None if
|
||||
no usable lines were found."""
|
||||
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
|
||||
lines = cv2.HoughLinesP(
|
||||
edges, 1, np.pi / 180, threshold=100, minLineLength=gray.shape[1] // 4, maxLineGap=20
|
||||
)
|
||||
if lines is None or len(lines) == 0:
|
||||
return None
|
||||
|
||||
angles = []
|
||||
for line in lines:
|
||||
x1, y1, x2, y2 = line[0]
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
if dx == 0:
|
||||
continue
|
||||
angle = np.degrees(np.arctan2(dy, dx))
|
||||
if -45 <= angle <= 45:
|
||||
angles.append(angle)
|
||||
if not angles:
|
||||
return None
|
||||
return float(np.median(angles))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("hough_deskew: usage: hough_deskew.py <image-path>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = sys.argv[1]
|
||||
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
print(f"hough_deskew: could not read image: {path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
angle = _angle_from_min_area_rect(img)
|
||||
if angle is None:
|
||||
angle = _angle_from_hough_lines(img)
|
||||
if angle is None:
|
||||
print("hough_deskew: no usable contour or line angle found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"{angle:.4f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user